Components
A component is a markdown file with frontmatter and declared props, invoked with a JSX-style tag. Documents stay valid, readable markdown everywhere.
Defining a component
Frontmatter becomes meta. The props block declares typed props. Text supports {meta.key} and {props.key} interpolation.
---
emoji: Hello
props:
name:
type: string
required: true
---
{meta.emoji}, {props.name}!Invoking a component
Capitalized JSX tags become component invocations. Names resolve from the component search directories (default components and .). Dotted names map to paths — <Tips.Formatting /> resolves to Tips/Formatting.md.
<Greeting name="world" />Slots & children
<Content /> acts as a slot for the children passed to a component. Named slots (slot="left") place children into specific regions.
<Card title="Notes">
Anything here becomes the card's children.
</Card>Returning values
A component returns one thing. Without returns, that is its rendered Markdown — the component above renders text, and as binds that text as a string. A returns declaration makes it a value component instead: it renders nothing, must be invoked with as, and binds the JSON value its single top-level <Return /> produces, validated against the schema.
---
returns:
passed: { type: boolean }
summary: { type: string }
---
```js eval
const verdict = { passed: true, summary: "no findings" };
```
<Return value={verdict} />The caller renders whatever presentation it wants from the value.
<Review as="review" />
<If condition={review.passed}>
Review passed: {review.summary}
</If>A root document uses the same two modes without the capture. Running a root that declares returns prints only its validated value as JSON; body output moves to stderr under --verbose.
$ xmd run review.md
{"passed":true,"summary":"no findings"}TypeScript components
A .ts file whose default export is a generator function is a component too. It receives validated props, declares them with a named props export, and reads its content with content().
import { content } from "@executablemd/core";
export const props = {
type: "object",
properties: { title: { type: "string" } },
required: ["title"],
additionalProperties: false,
} as const;
export default function*(props) {
const directory = yield* useTempDir();
const body = yield* content();
return `## ${props.title}\n\n${body}`;
}Resources it acquires belong to the invocation: they stay alive while the content it projects runs, and are released once that content has stopped — so a component can hold something open for its children without managing a scope itself. Durable effects it performs are journaled and replayed as usual.
Which form am I?
hasContent() answers from how the element was written, not from what it renders: <Thing>…</Thing> and <Thing></Thing> both have content — content that renders an empty string is still content — and only <Thing /> does not. Asking never renders the children, so a component whose two forms mean different things can branch before projecting anything.
Outliving the invocation
Invocation lifetime is right for a wrapper, and wrong for a component that hands something back. A self-closing component returns a value the caller uses after the invocation is over — releasing the resource behind it at the boundary would return a name for something that no longer exists.
retain() gives that resource invocation-site lifetime instead. Each call opens an isolated child of the invoking scope and runs the factory there, so the resource lives as long as that scope does — for the caller and its later siblings — while everything else the factory touches stays inside the child. Only the value it provides crosses back.
import { content, hasContent, retain } from "@executablemd/core";
export default function*() {
if (yield* hasContent()) {
// A wrapper: the resource is alive exactly while the content expands.
yield* useThing();
return yield* content();
}
// Standalone: the caller uses this handle after the invocation is over.
return yield* retain(useThing);
}<Thing as="handle" />
The resource behind {handle} is still running here.Retaining is a lifetime, not authority over the caller. A component cannot reach through retain() to set a context value or install middleware that its caller's later siblings would observe, and it never receives the invoking scope itself.
Retaining also delays release rather than opting out of it. The invocation site is an ordinary scope, so when it finishes, fails, or is cancelled, everything retained into it is torn down, innermost first. Nothing escapes structured concurrency and nothing needs an explicit release.
retain() belongs to TypeScript components. An eval block is durable — a replay restores its values without running it — so a resource retained from one would have nothing to re-establish it, and the call is refused rather than silently succeeding.
Scoped resources
<TempDir> is built into core — no search directory, no file to install. Written with content it gives that content an isolated working directory: nested components, code blocks, processes, and agents all observe it, without being handed a path.
<TempDir>
```sh exec
pwd
```
</TempDir>Written self-closing there is nothing to wrap, so it renders the directory's path and keeps the directory alive for the siblings that follow. as captures that path like any other value.
<TempDir as="workspace" />
Files written under {workspace} live until the document ends.Either way cleanup is automatic and unconditional — on success, on failure, and on cancellation. There is no retain prop and nothing is kept after a failure. Because the directory is the invocation's own resource, anything the content started — a daemon, a watcher — is stopped before the directory is removed.
One limitation, and it differs by form. Each run creates a new directory, while a recorded effect is matched by its description — so resuming from a partial journal would replay a result naming a directory that has been removed, and skip the filesystem work it stands for.
Inside a wrapping <TempDir> that is caught: the effect fails, the whole execution fails with it, and nothing after the component runs. It is not a diagnostic the document can collect and continue past — re-run from the beginning.
The standalone form has no such guard. A directory it retains belongs to the scope that invoked it, and a component cannot install anything there, so a later sibling replaying against a captured path is not detected — it continues with the recorded result, and nothing notices that the directory it came from is gone. Resuming a document that captures a temporary path is unsupported.
Reading and writing files
<File> is built into core too. It takes one prop — a path relative to the working directory — and does the obvious thing with it. Self-closing, it reads and renders the file's text.
<File path="request.md" />as captures that text and renders nothing, like any other component that returns text — which is how a prompt lives in a file instead of in the document.
<File path="prompts/review.md" as="instructions" />
<Prompt>{instructions}</Prompt>Written with content it writes instead, expanding its children first. It renders nothing at all: no output, no path, no file handle. Missing parent directories are created, and an existing file is replaced. Since the path is relative to the contextual working directory, it composes with <TempDir> without either component knowing about the other — the shell in the block below finds the file where <File> put it.
<TempDir>
<File path="fixtures/request.md">
Request content
</File>
```sh exec
cat fixtures/request.md
```
</TempDir>What gets written
Exactly what the children rendered. Nothing is added, trimmed, normalized, or reformatted — so where you put the tags is where the file's first and last bytes come from. On one line, that is the whole file:
<File path="a.txt">one line</File>writes one line — no trailing newline. On its own line, the breaks around it are inside the element, so they are content too:
<File path="a.txt">
one line
</File>writes \none line\n . If you want a file to start or end a particular way, say so with where the tags go; the component never guesses. It is UTF-8 text only — no binary data, no configurable encodings, no append or patch modes.
Staying inside the workspace
Everything <File> touches stays inside the working directory, checked in two stages that run at different times.
The first is pure path arithmetic: an absolute path and a .. escape are refused with no filesystem call at all, so the failure says nothing about what the path named. Only a whole .. segment escapes — ..notes.md is just a file with an odd name. For a write this happens before the children expand, so an unusable path costs nothing and its message is about the path rather than about what the children then did.
Path arithmetic alone would not be enough, because a symlink inside the directory can point anywhere. The second stage resolves whichever part of the path already exists and checks that — so a symlink staying inside is ordinary and gets followed to the file it names, and one that leaves is refused before anything outside is read or changed. For a write it runs after the children finish and immediately before writing, because a child can change what the path means — swapping a directory for a symlink out of the workspace, say. Resolving any earlier would approve one destination and write to another.
The working directory is inside itself, so . is not an escape — it is a directory, and it fails as one.
The commit point
Children expand completely before anything is written, so a failing block leaves the existing file exactly as it was — and because the write has nowhere to render a diagnostic, it fails the invocation rather than writing the diagnostic into your file. The write itself lands through a rename, and that rename is the commit point:
- A failure or cancellation before the rename leaves the previous file untouched.
- Once the rename begins, what anyone sees is the complete old file or the complete new one — never a partial write.
- A commit is not a transaction.
renamecannot be interrupted once it starts, and a cancellation arriving afterwards does not undo it.
So the promise is that no write is ever half visible — not that a finished write can be taken back.
Which means a failure can say three different things about your file, and only two of them are conclusions. If rename itself throws, the component genuinely cannot tell whether the commit happened: filesystem middleware may do work on either side of the call it wraps, so the throw may have arrived before the rename or after it succeeded. It says so rather than guessing.
- Preparation failed — The previous file is unchanged.
- The rename threw — Whether the replacement committed is unknown: the target holds either the complete previous content or the complete replacement, never a partial write.
- The rename returned — The file was written.
What a failure tells you
The message names the path you wrote and nothing else — not the resolved working directory, not where a symlink pointed, not the temporary file. Reporting where an escape led would perform the disclosure the refusal exists to prevent.
That includes errors from the filesystem itself, which name the path they failed on. Every call is wrapped, and nothing from the error is reproduced: its code selects a phrase from a fixed list — a component of the path is not a directory — and an unrecognized code selects the filesystem operation failed. The code is never printed, because whatever implements the filesystem can put a path or a newline in it as easily as ENOENT.
If removing the temporary fails, you are told — a file you did not create may be sitting next to one you did. That report names the path you wrote, never the generated temporary, and it never replaces a write failure it accompanies. When both fail you get both, plus a sentence saying what the directory now holds:
cannot write "notes.md": the destination is on a different filesystem.
cannot clean up "notes.md": permission denied.
Whether the replacement committed is unknown: the target holds either the
complete previous content or the complete replacement, never a partial write.
A temporary file beside it may remain.What this is not
Containment is judged against the filesystem as <File> observes it, which holds while the filesystem is stable. It is not a sandbox: another process can replace a directory with a symlink between the moment a path is checked and the moment it is used. Resolving a write's destination immediately before writing closes that window for the case your document controls — its own children — but check-then-use does not become atomic by being ordered more carefully. Containment that does not depend on observed state is tracked in issue #227.
Finding files
<Glob> answers the other half of the question: not "read this file" but "which files are there". It takes include — at least one glob pattern — and binds the paths it found. It declares a return value, so it renders nothing and as is required.
<Glob include={["**/AGENTS.md"]} as="instructionPaths" />exclude is optional and wins over include.
<Glob
include={["**/*.md"]}
exclude={[".git/**", "**/node_modules/**"]}
as="docs"
/>It is decided per file, against that file's own path — so a pattern matching a directory removes nothing by itself, because directories are not results. vendor removes nothing at all; vendor/* removes what is directly inside and keeps vendor/deep/keep.md, since * never crosses a separator; vendor/** removes the whole subtree.
Only that last form lets the directory be skipped rather than walked, which is why .git/** and **/node_modules/** cost nothing on a real repository. Every other exclusion walks the subtree and filters its files one at a time — pruning is an optimization, and it never changes the answer.
Patterns are relative to the same contextual working directory <File> uses, and so are the results — which is what lets the three components become a pipeline. Build a fixture, discover it, read every match, all without a path that means something only on your machine:
<TempDir>
<File path="docs/guide.md">Guide</File>
<File path="docs/api/reference.md">Reference</File>
<File path="README.md">Readme</File>
<Glob include={["docs/**/*.md"]} as="docs" />
<Each in={docs} let="path">
<File path={path} />
</Each>
</TempDir>What you get back
A set of relative paths, and each word there is doing work. Paths use / on every platform. A file that several patterns match is one result. And they are sorted by code point — not by locale, whose answer depends on how the host is configured, and not in the order the filesystem handed entries back, which is not an order at all. A document that branches on a listing branches the same way everywhere.
Finding nothing is a result: an empty array, and the document carries on.
A leading dot is an ordinary character, so there is no hidden-file option. * matches one like anything else — *.md finds .hidden.md — and a pattern finds a hidden file exactly when it says so.
Only files come back. Directories are not results, and neither are symbolic links: a link is a link, not a file. A link to a directory is not descended into either, which is what keeps a search inside the working directory without having to judge where anything points — traversal follows only real directories, so it cannot leave and cannot cycle.
When a pattern is wrong
A pattern that cannot match anything a relative search produces — an absolute one, one starting with .. , or an empty string — fails rather than quietly contributing nothing. An empty result has to keep meaning there are no such files, so a typo must not also produce one. Only a whole leading .. segment counts; ..notes.md is just a file with an odd name.
A missing working directory, one that turns out to be a file, and a directory that cannot be read all fail too. That last message names no path at all — what failed is a directory under the working directory that you never wrote — and as with <File>, nothing from the underlying error is reproduced: its code selects a phrase from a fixed list.
Parsing generated JSON
<Parse> and <SafeParse> are built into core as well. They turn content into a value the rest of the document can branch on, validated against a schema. Both take schema and as, render nothing, and parse whatever their children expand to — an agent's reply, a captured fence, a file's contents.
<Parse> is the strict one: it binds the validated value, or fails.
<Capture as="schema" select="code[lang=json]">
```json
{
"type": "object",
"properties": { "passed": { "type": "boolean" } },
"required": ["passed"]
}
```
</Capture>
<Parse schema={schema} as="verdict">
<Prompt>Answer with JSON only: did the change pass review?</Prompt>
</Parse>
Review passed: {verdict.passed}The schema is either captured JSON text, as above, or an already structured value — both compile the same way. It compiles before the children expand, so an unusable schema fails before the document spends a model call on content it could never judge.
Neither component transforms what it validates. A declared default is not inserted, a type is not coerced, an undeclared property is not removed. What you bind is exactly what the content said. Parsing is also provider-neutral: no agent is involved, and no repair happens behind your back.
Inspecting a failure
<SafeParse> binds a result instead of failing. On success that is { ok: true, value }; on failure, { ok: false, input, errors } — the text that failed, kept exactly as it arrived, and what was wrong with it. Malformed JSON arrives as a single issue with keyword: "parse", so both kinds of failure read the same way.
<SafeParse schema={schema} as="result">
<Prompt>Answer with JSON only: did the change pass review?</Prompt>
</SafeParse>It absorbs JSON syntax and schema failures, and nothing else. An unusable schema still fails, and so does a failing child.
A bounded repair prompt
Because the failure is a value and <If> chooses a branch, the whole retry is ordinary Markdown rather than something hidden in the component. A successful result is read straight off result.value. A failed one quotes result.input, renders every issue in result.errors into one corrective prompt, and validates the reply strictly with <Parse>.
<SafeParse schema={schema} as="result">
<Prompt>Answer with JSON only: did the change pass review?</Prompt>
</SafeParse>
<If condition={result.ok}>
Review passed: {result.value.passed}
<Else>
That first answer was not usable:
```
{result.input}
```
<Each in={result.errors} let="issue">
- {issue.instancePath} {issue.message}
</Each>
<Parse schema={schema} as="verdict">
<Prompt>Correct the JSON above so it satisfies every point.</Prompt>
</Parse>
Review passed: {verdict.passed}
</Else>
</If>The retry is bounded by construction, not by a counter. <If> expands exactly one branch and never the other, so at most two attempts can execute: the initial <SafeParse>, and — only when that one failed — the single corrective <Parse> inside <Else>. When the first answer parses, the failure branch never expands, so its prompt is never sent. Nothing in the failure branch reaches back to the beginning, so there is no third attempt to bound.
Asking a person
<WebForm> stops the workflow and asks someone a question. It opens a form in the browser, waits for one answer, binds it, and carries on — so a document can put a human decision in the middle of an otherwise automatic run.
```js eval
const reviewSchema = {
type: "object",
properties: {
decision: { type: "string", enum: ["approve", "reject"] },
note: { type: "string" },
},
required: ["decision"],
additionalProperties: false,
};
```
<WebForm schema={reviewSchema} as="review">
# Review required
Read the plan above and decide.
</WebForm>
```js eval
if (review.decision === "reject") {
output(`Stopping: ${review.note ?? "no reason given"}`);
}
```The form is generated from schema, a draft-07 JSON Schema, and the children become the page's own content above it. The component renders nothing: what it produces is the validated answer, so as is required.
uiSchema is optional and controls presentation only — it is RJSF configuration rather than a schema, and is never validated as one.
<WebForm
schema={reviewSchema}
uiSchema={{ note: { "ui:widget": "textarea" } }}
as="review"
>
Read the plan above and decide.
</WebForm>The server runs on 127.0.0.1 behind a single-use, unguessable URL, which is printed as well as opened — so a workflow over SSH, or on a machine with no browser, still gives you a link that works. The page is entirely self-contained: it makes no request off the machine, and the answer is validated again on the server before the document ever sees it.
Only the answer is journaled. Resuming a document that already has one binds the recorded value without starting a server or asking anyone twice.
Asking without choosing how
<WebForm> is a browser form by construction — a document that writes it has picked a transport. <Elicit> asks the same kind of question without saying where the asking happens.
```js eval
const responseSchema = {
type: "object",
properties: {
decision: { type: "string", enum: ["approve", "reject"] },
note: { type: "string" },
},
required: ["decision"],
};
```
<Elicit schema={responseSchema} as="response">
Review the implementation plan and provide your decision.
</Elicit>
```js eval
if (response.decision === "reject") {
output(`Stopping: ${response.note ?? "no reason given"}`);
}
```schema and as are required, and that is the whole surface. There is no mode or provider prop, no uiSchema, and no built-in approve, decline, or cancel: the schema defines every response available. Where the question is actually put to someone is the host's decision, installed through the Elicitation Api — a browser form under the CLI today, a terminal or an editor integration later, or answers the document supplies itself. Swapping one for another changes no Markdown.
That is the reason it is contextual rather than an author-facing option. A mode prop would make every document that used it a document about its own transport, and a workflow written for a browser could not then run anywhere else.
The order is fixed and observable: the schema compiles first, so an unusable one fails before anything is asked; the content expands into the request; and the answer is validated against the same schema before it binds. An answer that fails it fails once — correction belongs inside the provider, and retry belongs in visible <If> control flow rather than in a hidden loop. Resuming restores the recorded answer without asking again.
Reach for <WebForm> when the browser form itself is the point and you want RJSF presentation control; reach for <Elicit> when what matters is the question.
Answering from the document
A component that elicits internally asks whoever the host's provider reaches. Sometimes the document already knows the answer — exercising somebody else's component non-interactively, a demo, a stretch of a longer run that should not stop for a person. <Answers> is how a document says so.
<Answers>
<Answer template="Approve {?what}?" value={{ decision: "approve" }} />
<Answer value={{ decision: "reject", note: "unreviewed" }}>
Deploy {?service} to production?
</Answer>
<ReviewGate plan={plan} as="verdict" />
</Answers>Each <Answer> is a matcher. Its template is compared against the whole message the elicitation asked: literal text constrains, {?name} matches any text and binds nothing, and {binding} requires an existing value at that spot. A matcher with no template matches anything. Write the template as a prop for one line, or as children when the question runs long.
Selection is two rules and nothing else: the first declared matching answer wins, and a matcher is not used up by answering — it keeps answering everything it matches. Which means declaration order is real: a broad template above a narrow one shadows it for good.
For as long as its body lasts, the region is the provider. Everything else is unchanged — each answer is still checked against the schema of whatever asked for it, so a value that does not fit fails exactly as a person's answer would.
An elicitation no matcher answers is an error by default, and the diagnostic names both the message and every template that was tried. A document supplying answers is saying what will be asked, and being wrong about that is a mistake rather than a reason to go find someone. delegate says the other thing on purpose: whatever this region cannot answer passes outward, to an enclosing region or to whatever the host installed.
<Answers delegate={true}>
<Answer template="Approve {?what}?" value={{ decision: "approve" }} />
<ReviewGate plan={plan} as="first" />
<ReviewGate plan={other} as="second" />
</Answers>Regions nest, and the nearest one answers — ordinary middleware nesting. A matcher that never fires is not a mistake; a branch that did not run is still a branch you were right to describe. Resuming matches nothing, so a region needs only what the run will actually ask.
How it renders
- Component references are resolved from the filesystem and expanded recursively (with cycle detection).
- Markdown is healed at execution boundaries with
remend, so formatting never bleeds across components. - Because expansion is markdown-in / markdown-out, the document remains a clean file in any viewer.
Next: Exec & Eval →