Control flow
<If> chooses one branch of a document, and <Loop> repeats a region a bounded number of times. Both are directives the expansion engine handles directly, like <Each> and <Capture> — there is no If.md or Loop.md to import.
Choosing a branch
condition is the only prop, and it must be a boolean — there is no truthy or falsy coercion, so a string, number, array, or null is an error rather than a branch. The optional <Else> block holds the alternative and is written once, as a direct child.
<If condition={hasFailures}>
## Test failures
<FailureReport />
<Else>
All checks passed.
</Else>
</If>Without <Else>, a false condition renders nothing.
<If condition={releaseChanged}>
> [!WARNING]
> Release configuration changed — update the release spec.
</If>An <If> has exactly two branches, so <Else> is its final substantive child. Blank lines between </Else> and </If> are formatting and are ignored, but any content there belongs to neither branch and is an error rather than a third region quietly attached to the true branch.
<If condition={hasFailures}>
Failures found.
<Else>
All checks passed.
</Else>
This line is an error — it belongs to neither branch.
</If>Only the selected branch expands
The branch that is not selected is not hidden output — it never expands. Nothing in it imports a component, runs an exec or eval block, reaches an LLM provider, touches the filesystem, creates a binding, or writes a journal entry. Putting expensive work in a branch costs nothing when the condition does not select it.
Nesting
Nested <If> blocks select independently, and each one owns the <Else> beneath it.
<Review as="review" />
<If condition={review.passed}>
Review passed: {review.summary}
<Else>
<If condition={review.blocking}>
Blocked: {review.summary}
<Else>
Needs revision: {review.summary}
</Else>
</If>
</Else>
</If>Bindings survive the branch
<If> creates no binding scope. A capture the selected branch makes behaves like inline content and stays available after </If>, so both branches can bind the same name and later content reads it without knowing which one ran. The unselected branch binds nothing at all.
<If condition={verdict.passed}>
<Capture as="headline">All {verdict.total} checks passed.</Capture>
<Else>
<Capture as="headline">{verdict.failed} of {verdict.total} checks failed.</Capture>
</Else>
</If>
<GitHubComment body={headline} />Repeating with Loop
<Loop> expands a region more than once, under a bound the document states. max is required and must be a positive integer — there is no unbounded form.
<Loop max={3}>
<Probe as="reading" />
Reading {reading.label}: {reading.value}
</Loop>Bindings carry across iterations
<Loop> creates no binding scope. Each iteration expands in the enclosing environment, so it reads what earlier iterations bound, and the final values stay available after </Loop> — that is how a document acts on what the repetition produced. This is the opposite of <Each>, whose per-item binding lives only for the iteration that renders it.
<Capture as="log">attempts:</Capture>
<Loop max={3}>
<Attempt as="attempt" />
<Capture as="log">{log} {attempt.status}</Capture>
</Loop>
<GitHubComment body={log} />Leaving early with Break
<Break /> ends the loop it is written in. It stops the rest of the current iteration — content after it does not expand, so it imports no component, runs no block, and reaches no provider — and skips the iterations that were left. A nested <Loop> handles its own break.
<Loop name="planning" max={5}>
<Plan />
<Review as="verdict" />
<If condition={verdict.passed}>
<Break />
</If>
</Loop>
<If condition={verdict.passed}>
Plan approved: {verdict.summary}
<Else>
Five revisions were not enough — escalating.
</Else>
</If>Which loop a <Break /> means is decided by where you wrote it. Content you hand to a component is still your text, so a break in it ends your loop — the component finishes rendering, and the break lands when the invocation returns. A <Break /> a component writes in its own body belongs to a loop in that body, never to the loop that invoked the component.
<Loop max={3}>
<Panel title="Attempt">
<Attempt as="attempt" />
<If condition={attempt.ok}>
<Break />
</If>
</Panel>
</Loop>A malformed <Break /> — one carrying props or content — does no control action at all. It is not an instruction the loop can act on, so it reports and the loop keeps its own course.
Exhaustion is not failure
Reaching max completes the loop normally and produces no diagnostic. Whether five rejected plans mean the work failed is a decision only the surrounding document can make, so you write it as an <If> on whatever the body bound — as the review loop above does. A retry limit is explicit document policy, never something <Loop> decides on your behalf.
What the journal records
A loop writes its own entries rather than leaving you to reconstruct what it did from whatever the body happened to record. Each iteration gets an entry before it runs, carrying its zero-based identity — so the entry means the iteration was entered, not that its body finished, and an empty iteration is on the record exactly like a busy one. When the loop finishes it writes one terminal entry saying how: exhausted, break, or error — with the number of iterations entered.
loop_iteration loop:4:iteration:0 { "iteration": 0 }
loop_iteration loop:4:iteration:1 { "iteration": 1 }
loop_iteration loop:4:iteration:2 { "iteration": 2 }
loop loop:4 { "iterations": 3, "outcome": "break" }That is what separates a loop that broke on its final iteration from one that exhausted the same bound: identical iteration entries, different terminal outcome. The identity is internal — the loop keeps no counter your document can read. It exists so a run can resume into the iteration it stopped in, not so the body can branch on it.
When a run resumes, the terminal entry is validated rather than trusted. An iteration entry names its own number, so replaying it already checks it; a terminal entry names only the loop, so a recorded exhausted would otherwise stand in for a resumed run that actually broke. If the stored outcome or count disagrees with what the resumed run reached, the run stops with a stale-input error instead of continuing under an outcome it never reached.
And a durability failure is never recorded as a loop outcome. If the journal is already wrong about this run — a resource it recorded is gone, or the replay diverged — the loop writes nothing, and the failure you get back is that failure itself, at the operation that hit it, rather than a later mismatch somewhere downstream. Only an ordinary document failure produces the error outcome.
A stale-record diagnostic names the loop and the outcome the run derived. It never quotes what the journal held: that is external data, and a diagnostic that reproduced it would carry whatever it contained into your logs and output.
Interrupted runs
A terminal entry means the loop finished. A loop that is interrupted — the run was cancelled, or the process died — has iteration entries and no terminal entry. Nothing is written on the way down on purpose: an entry appended during teardown would land after the iteration entries a resumed run still has to replay, and would break the resume.
The same holds one level up. A run that finishes ends with a root close: ok on success, err for a document failure, which a loop's error outcome precedes. An interrupted run has no root close at all.
Completed terminal entry, exhausted or break root close ok
Failed terminal entry, error root close err
Interrupted iteration entries only no root closeSo the journal tells you whether a run finished, and deliberately does not tell you why an unfinished one stopped — a cancellation and a crash leave the same durable state. They mean the same thing to a reader and take the same recovery path: hand the journal to a new run, which replays what completed and executes the rest live. Which one happened is runtime knowledge, not journal state.