# Authoring an Orchestration Program

A program is a TypeScript module. It default-exports `defineRun(...)`. It can
import `@yaag/runtime` and `typebox`. A program file can import other modules;
an inline program cannot.

The deep reference is `<program dir>/.yaag/types/runtime/index.d.ts`. It holds
the author surface only, not the internals; `typebox` gives `Type`, `Static` and
`TSchema` from its root, and no subpath. This page gives the rules, not types.

## The Run

`defineRun` takes a name, an optional description, an optional argument schema,
and a `run` body. The body gets a context with `args` and `spawn`. What the body
returns is the result of the Run.

<!-- embed: docs/examples/01-minimal.ts -->

```ts
export default defineRun({
  name: "minimal",
  description: "Asks one Agent for one short answer.",
  async run(ctx) {
    const agent = await ctx.spawn({ name: "writer" });
    return await agent.ask(prompt`Write one sentence about the sea. Report only that sentence.`);
  },
});
```

Keep the module top level side-effect free. `yaag describe` imports the module
and executes its top level. The one exception is a Catalog Resolve:
`await $yaag.<scope>.importAgent(name)` at module top level is permitted.

## Arguments

Declare arguments with a typebox object schema. The Orchestrator validates the
arguments before it starts any Agent, so a bad call costs nothing. `ctx.args` is
typed from the schema.

<!-- embed: docs/examples/02-args.ts -->

```ts
  args: Type.Object({
    topic: Type.String({ description: "What to write about" }),
    sentences: Type.Optional(Type.Integer({ description: "How many sentences" })),
  }),
```

## Agents and spawn restrictions

`defineAgent` holds policy that does not change: the prompt, the model, the
thinking level, the tools, and the skills. A spawn holds topology: the name, the
working directory, and the worktree request. `ctx.spawn(definition, overrides)`
puts the two together.

The Agent row of the Run tree shows the model the Agent runs and its thinking
level, as `anthropic/claude-opus-5:medium`. A level of `off` is shown; a Run
replayed from a Cassette shows the model alone.

`tools`, `disallowedTools`, `skills`, and `disallowedSkills` take **names**, not
paths. `disallowedTools` and `disallowedSkills` apply last. An unknown skill
name rejects the spawn. A spawn `name` must not hold `/` or `:`. Such a name
fails with `AGENT_NAME_INVALID`, before the Agent starts.

### Letting an Agent ask you a question

`canAskUser: true` gives the Agent the `request_user_input` tool. The Agent
asks one question with it, and its turn stops on that question. The Run pauses
and its result holds the question, with the name of the Agent that asked it.
The Agent reads a system prompt that names the tool.

The Agent asks one question in a turn. A second call in the same turn gets the
error `one question per turn`.

The tool takes a second argument, `context`: why the Agent asks, in 1 to 3
sentences. When the Agent gives none, yaag can write a Question Summary for
it; see
[The summary of an Agent question](configuration.md#the-summary-of-an-agent-question).

The capability is never removed in silence. A `tools` allowlist that does not
hold `request_user_input`, or a `disallowedTools` that holds it, makes the
spawn fail.

`appendSystemPrompt` places its text inside the Agent System Prompt, the text an
Agent reads by default. It no longer hides the machine's own `APPEND_SYSTEM.md`:
the prompt prints both texts, the program's text first. `systemPrompt` replaces
that prompt, which removes the
Agent's runtime facts, its tool list and the Rules. See
[Examples](examples.md#08--the-system-prompt).

### Shaping the system prompt

`systemPrompt` takes a string or a Prompt Definition. A string is the whole
prompt, literally. A Prompt Definition builds the text at spawn time:

<!-- embed: docs/examples/08-system-prompt.ts -->

```ts
      systemPrompt: defineSystemPrompt(
        ({ agent }) => prompt`
          ${yaagSystemPrompt({ audience: "program" })}

          House rules for ${agent.name}: ${RULES}
        `,
      ),
```

The builder must be pure and synchronous. It runs in the Orchestrator, once for
each spawn. A Fork inherits the text its source rendered, so the builder does
not run again for a Fork. A builder that throws rejects the spawn with an error
that names the Agent.

`agent` holds two strings: `agent.name`, the Agent name the Run allocated, and
`agent.runName`, the name of the Run.

`ctx` is the Prompt Context. It is **write-only**: each call writes a marker for
a fact that exists only inside the Agent process, and yaag replaces the marker
with the real text there. Do not read, match, or split what a `ctx` call
returns. There are eleven calls:

| Call | Writes |
|---|---|
| `ctx.renderModel()` | the model, as `provider/id` |
| `ctx.renderPid()` | the Agent process id |
| `ctx.renderStartedAt()` | the time the Agent started |
| `ctx.renderCurrentDateTime()` | the time of the current turn |
| `ctx.renderCwd()` | the Agent working directory |
| `ctx.renderTools()` | the tool list, one line for each tool |
| `ctx.renderAppendSystem()` | the user's `APPEND_SYSTEM.md` |
| `ctx.renderProgramAppend()` | the text of `appendSystemPrompt` |
| `ctx.renderContextFiles()` | the context files pi loaded |
| `ctx.renderSkills()` | the skills pi loaded |
| `ctx.renderPiDefaultPrompt()` | the default prompt pi builds alone |

`yaagSystemPrompt()` and each `ctx.render*()` call return a branded string, so
the compiler refuses that text in a plain `systemPrompt`. yaag also refuses it
when it starts: a plain-string `systemPrompt` that holds a marker rejects the
spawn with a message that names `defineSystemPrompt`, and a marker name yaag
does not know rejects the spawn too. A spawn that sets `appendSystemPrompt`
whose prompt calls no `ctx.renderProgramAppend()` keeps running and writes one
warning, because yaag drops that text.

`ctx.renderAppendSystem()` writes nothing until yaag can tell the user's
`APPEND_SYSTEM.md` from the program's `appendSystemPrompt`; the user's file
comes out of `ctx.renderProgramAppend()` until then.

`yaagSystemPrompt({ audience, agent })` gives you the default text, so a program
can add to it instead of replacing it. `audience` is `"program"` (the default)
or `"user"`. `"user"` prints a second paragraph that says a person can read what
the Agent writes and names the tool to reach that person; no program can reach
that person yet. With `agent` the text prints an `Agent: … Run: …` line;
without it that line is absent.

A definition and a spawn call take the same policy fields (`AgentPolicy`):
model, thinking, the two prompt fields, `inherit`, `extensions`,
`configExtensions`, the tool and skill lists, and `askDefaults`. A spawn call
adds the topology: `cwd`, `worktree`, and `parent`. `parent` takes a Handle or
a Group. `askDefaults` applies to every Ask on that Agent unless the Ask
overrides a field. A relative
`extensions` entry resolves from the Orchestration Program file, also when the
definition lives in another module.

<!-- embed: docs/examples/03-fan-out.ts -->

```ts
const reviewer = defineAgent({
  name: "reviewer",
  tools: ["read"],
  disallowedTools: ["yaag_run"],
  skills: [],
});
```

## Import a registered Definition

A Catalog Resolve takes an Agent Definition from a Registry File by name. The
program does not declare the Definition: the Registry File names the module
file and the export, and yaag imports it.

<!-- embed: docs/examples/10-catalog-resolve.ts -->

```ts
// Top-level `await` is permitted only for a Catalog Resolve (ADR-0053).
const greeter = await $yaag.global.importAgent("greeter");

export default defineRun({
  name: "catalog-resolve",
  description: "Spawns an Agent Definition that a Registry File registers by name.",
  async run(ctx) {
    const agent = await ctx.spawn(greeter);
    return await agent.ask(prompt`Write one sentence about the sea. Report only that sentence.`);
  },
});
```

`$yaag` has two scopes, and they are independent:

- `$yaag.global` reads the Registry File of the Global Config directory,
  `<config dir>/registry.json`. `YAAG_CONFIG_DIR` moves that directory.
- `$yaag.project` reads `<program dir>/.yaag/registry.json`. It fails when the
  Run has no Program Directory.

A resolve that does not succeed throws one of three codes:

- `CATALOG_ENTRY_NOT_FOUND` — there is no Registry File, or no agent entry in
  it has this name, or the scope has no directory.
- `CATALOG_ENTRY_AMBIGUOUS` — two or more agent entries have this name.
- `CATALOG_ENTRY_UNRESOLVABLE` — the entry is there, but the resolve cannot
  complete: the Registry File does not parse, the entry is malformed, the
  module file is not there, the export is not there, or the export is not an
  Agent Definition.

## Worktrees

`worktree: true` puts the Agent in its own git worktree, on a fresh branch. Two
Agents that write files then cannot collide. The worktree outlives the Run: yaag
creates it and never removes it. Merge or delete the branch yourself.

## Asks and limits

`handle.ask(prompt, options)` is the only conversational verb. The soft limits
are `maxTurns`, `maxToolCalls`, and `maxDurationMs`. A soft limit steers the
Agent with `wrapUpPrompt`, gives it one more turn, then fails the Ask with
`ASK_LIMIT`. The Handle stays alive, so the program can ask again.

`timeoutMs` is different: it kills the Agent and fails with `ASK_TIMEOUT`. Use
it as the last resort, not as a budget.

`outputSchema` makes the Ask return data against a typebox object schema. A
result that never validates fails with `ASK_INVALID_OUTPUT`.

<!-- embed: docs/examples/04-controlled-ask.ts -->

```ts
      const report = await agent.ask(prompt`Read README.md and report your verdict.`, {
        maxTurns: 6,
        maxToolCalls: 12,
        maxDurationMs: 120_000,
        wrapUpPrompt: "Stop the work and report what you have now.",
        outputSchema: Report,
      });
```

Use the `prompt` tag for prompt text. It removes the indentation that a
template literal keeps.

## Asking the user

The program asks with `ctx.askUser`. An Agent asks with `request_user_input`,
when the spawn sets `canAskUser: true`; see
[Letting an Agent ask you a question](#letting-an-agent-ask-you-a-question).

`await ctx.askUser("ship it?", { key: "ship" })`
records the question and pauses the Run. `await ctx.pause("the plan needs a
decision")` pauses without a question. Neither call comes back in this Run: the
program stops at that line, every Ask in flight is given the time it needs, and
the Run publishes a Checkpoint. `executeRun` then gives back
`{ outcome: "paused", questions, reason, checkpoint }` instead of
`{ outcome: "completed", value }`. Give each question its own `key` when the
program asks more than one: two questions that claim one id are refused with
`PAUSE_REFUSED`.

Add `context` when the question needs background:
`await ctx.askUser("ship it?", { key: "ship", context: "the tests pass but the
changelog is empty" })`. The context is free text that helps the person answer.
The person reads the summary, else the context, else the question alone.

You answer the questions with a resume. `yaag run p.ts --resume <checkpoint>
--answers '{"ship":"yes"}'` runs the program again, and the `askUser` call of
the question `ship` comes back with the text `yes` this time. yaag matches an
answer by the `key`, and by the hash of the question text when the call gives
no key. In pi, the `answers` parameter of `yaag_run` does the same thing as the
flag; see [A Run that pauses](cli.md#a-run-that-pauses). A question you do not
answer stays open: the program stops at that line
again, and the new Run pauses with a new Checkpoint. An answer key that names
no open question stops the resume with `RESUME_REFUSED`. See
[`--answers`](cli.md#usage) for the flag and the `@file` form.

## Record and resume

`--record <file>` writes a Cassette of every frame. `--resume <file>` runs the
program again and replays the recorded Asks. A resume needs the program too,
because a Cassette holds the history of a Run and never the program. See
[Examples](examples.md#05--record-and-resume).

## Fork and compaction

`handle.compact()` replaces an Agent's context with a summary of it, and
reports the context size before and after. `handle.fork()` spawns a new Agent
from a copy of this Agent's conversation: use it when one expensive Agent
builds context that several cheap Agents need. Both need a settled Ask
boundary, so a call during an Ask fails with `COMPACT_DURING_ASK` or
`FORK_DURING_ASK`.

- An Agent that exited cleanly stays forkable: build context, close the Agent,
  then fork its final state. An Agent killed during an Ask fails `FORK_REFUSED`.
- The fork inherits every spawn option of its source, including the concrete
  model it settled on, but not its name. Pass any spawn option to change that.
  The source becomes the fork's parent; pass `parent` to place it elsewhere.
- `fork({ compact: true })` compacts the copy before you get the Handle; a
  string becomes the compaction instructions.
- A fork of a Worktree Agent gets its own worktree, branched from the source's
  branch, so only committed work transfers. Forking needs a pi that supports
  `pi --fork`. See [Examples](examples.md#07--fork-and-compaction).
- `handle.fork({ fork: … })` is refused with `FORK_REFUSED`: the two fork verbs
  never overlap. `handle.fork()` of a Host Fork forks that Agent, and never the
  human's session again.

### A Host Fork of your pi session

`ctx.host` is the Host Source: the pi conversation that started the Run. It is
`undefined` unless the Run was started with the Host Session — `yaag_run({
forkHost: true })` in pi, or `yaag run --host-session <file> --host-tool-call
<id>` in a shell. Check it before you spawn.

`ctx.spawn({ name, model, fork: ctx.host })` is the one spawn verb for it. It
starts a Host Fork: an Agent that begins from a copy of that conversation.
Every ordinary spawn option applies — model, thinking, tools, skills,
extensions, system prompt — and several Host Forks may take one `ctx.host`.
A Host Fork has no parent unless you pass `parent`. `compact: true`, or a
string of instructions, summarizes the copied conversation before the Handle
comes back. The Agent's first turn is the first Ask your program sends.

Three failures: `FORK_REFUSED` for a `fork` that is not this Run's `ctx.host`
(an `undefined` that slipped past the type, a foreign `{ kind: "host" }`
object, or `handle.fork({ fork })`); `OPTIONS_CONFLICT` for `compact` without
`fork`; and `HOST_SESSION_UNUSABLE` when the Host Session cannot be used, which
stops the Run before it starts. See
[Examples](examples.md#11--a-host-fork-of-your-pi-session) and
[CLI](cli.md#usage).

## Lineage

`parent` names the Agent a spawn belongs under. It is data only: it sets the
Agent's place in the Run tree, so the TUI and the Run Summary draw the child
under its parent. It opens no channel between the two Agents, and it ends no
lifetime — every Agent still dies with the Run.

`parent` takes a Handle a previous spawn in this Run returned, and it works the
same way as an override on a definition: `ctx.spawn(reviewer, { parent })`. A
parent that already exited stays a valid parent. `parent` also takes a Group of
this Run, which puts the new Agent in that Group. Read [Groups](#groups) for
the rules.

An Agent cannot spawn. It can *ask* for a helper through `outputSchema`, and
the program decides:

<!-- embed: docs/examples/06-lineage.ts -->

```ts
    for (const helper of wish.helpers) {
      if (!ALLOWED_ROLES.has(helper.role)) continue;
      const agent = await ctx.spawn({ name: helper.role, parent: implementer });
      reports.push(await agent.ask(prompt`Review this report: ${wish.report}`));
    }
```

## Groups

A Group is a name in the Run tree. Use it to keep related Agents together, for
example the Agents of one ticket. A Group holds no conversation and it starts
no process.

`ctx.group({ name })` gives back a Group immediately. The call is not
asynchronous, so you do not write `await`. The value is opaque: read `name` and
`path` from it, and pass it on. You cannot build one yourself.

`parent` sets the place of the new Group. Give a Group, and the new Group goes
under it. Give a Handle of this Run, and the new Group goes under that Agent.
Give nothing, and the new Group is a root Group. The `path` of a Group is the
chain of Group names, joined with `/`, and it starts with the Agent name when
the Group sits under an Agent. A `planning` Group under a `ticket 01` Group
thus has the path `ticket 01/planning`, and a `reviews` Group under an Agent
`impl` has the path `impl/reviews`. An Agent name is unique in a Run, and a root
Group name comes from that same set, so no two Groups of one Run share a path.

Two failures: `GROUP_NAME_INVALID` for an empty name, or for a name that holds
`/` or `:`; and `GROUP_EXISTS` for a name that is already in use under the same
parent. The same name under two different parents is correct. A root Group and
an Agent cannot share a name: `ctx.group()` fails with `GROUP_EXISTS` when an
Agent of this Run already holds the name, and `ctx.spawn()` with the name of a
root Group gets a suffix (`lead-2`), as a repeated Agent name does. A Group
under a Group, or under an Agent, can still have the name of an Agent.

<!-- embed: docs/examples/12-groups.ts -->

```ts
    const ticket = ctx.group({ name: "ticket 01 (write the release note)" });
    const planning = ctx.group({ name: "planning", parent: ticket });
    const implementing = ctx.group({ name: "implementing", parent: ticket });

    const planner = await ctx.spawn({ name: "planner", parent: planning });
```

See [Examples](examples.md#12--groups).

## Profiles

A Profile is a named model configuration of one program. A Role is a name the
program gives to a job, such as `planner`. A Role Binding is what one Profile
gives to one Role: today an ordered list of model candidates. The user switches
the whole program with one Profile id, and edits no Agent Definition. For a
complete program, read [Examples](examples.md#09--two-vendors-one-flag).

`defineProfiles({ roles })` takes the Role names and returns a Profiles value
with `role` and `register({ id })`. `profiles.role.<name>` gives a declared
Role, and `profiles.role.ofName(name)` gives the same Role for a name known
only at run time. An undeclared name throws. A Profile builder gives
`provider(name)` and `bind(role)`. A binding builder adds `model(spec)` and
`fallback(spec)`:

<!-- embed: @yaag/cli/src/fixtures/profiles-program.ts -->

```ts
const profiles = defineProfiles({ roles: ["planner"] });
const planner = profiles.role.planner;

const claude = profiles.register({ id: "claude" });
claude
  .bind(planner)
  .model({ provider: "anthropic", name: "claude-opus-4-8", thinking: "medium" })
  .fallback({ provider: "anthropic", name: "claude-sonnet-4-8" });

const glm = profiles.register({ id: "glm" });
glm
  .provider("ollama")
  .bind(planner)
  .model({ name: "glm5.3", thinking: "medium" })
  .fallback("zai-org/glm5.3:low");
```

A candidate is a pattern string, or an object `{ provider?, name, thinking? }`.
Each candidate lowers to one pattern string:

- An object with `provider` gives `provider/name`.
- An object without `provider` takes the Profile default that `provider()` set.
  A repeat `provider()` replaces that default for the candidates after it.
- An object with no provider and no Profile default gives the bare `name`.
- A `thinking` level appends the `:level` suffix.
- A pattern string stays as written.
- The `model()` candidate comes first, then each `fallback()` in call order.

A Role reads its binding back. `roleName` is the declared name. `model` is
the lowered candidate list. `provider`, `modelName` and `thinking` are the
parts of the first candidate. On the Profiles value these fields read the
default Profile: the sealed default, else the first registered Profile. A
read before any `register()`, or of a Role with no `model()`, throws.

Give the Profiles value to `defineRun({ profiles, defaultProfile })`.
`defaultProfile` is optional: without it the first registered Profile is the
default. `defineRun` validates the declaration once and seals the value, so a
later builder call throws. It throws when the Profiles value registers no
Profile, when a Profile leaves a Role unbound, when a Role Binding has no
`model()`, or when `defaultProfile` names no registered Profile. Declare one
Profiles value per program: a second `defineRun` that resolves another default
from the same value throws too.

`yaag describe <program.ts>` prints the sealed picture as the `profiles` field:
the resolved `default` id, the declared `roles`, and `entries`, which gives the
lowered candidate list of each Role of each Profile. A program that declares no
Profiles prints `null`.

### Spawn an Agent from a Profile

`useProfile(factory)` declares a Profiled Definition: an Agent whose policy the
active Profile completes. The factory runs once per spawn, with a view of that
Profile. The factory takes `{ profile }`. `profile.role.<name>` gives the
Role as the active Profile binds it, and the factory copies the fields it
needs into an ordinary Agent config. `useProfile<typeof profiles>(factory)`
types `profile.role` after the declared names. Without the type argument,
read a name through `profile.role.ofName(name)`. `ctx.spawn(profiled, overrides)` passes the
result through `defineAgent` and takes the ordinary spawn path, so spawn
overrides stay topology-only. `ctx.profile` names the active Profile, and is
`undefined` for a program that declares none. A describe of such an export —
`yaag describe <file> --export <name>`, or `yaag_describe({ file, exportName })`
— reports the finding `PROFILED_DEFINITION`, because the Agent metadata comes
from the Profile the Run activates:

<!-- embed: @yaag/cli/src/fixtures/profiled-spawn.ts -->

```ts
export const plannerAgent = useProfile<typeof profiles>(({ profile }) => ({
  name: "planner",
  model: profile.role.planner.model,
  appendSystemPrompt: "Plan the work.",
}));
```

A spawn of a Profiled Definition fails when the program declares no Profiles,
and when the factory reads a Role that the program does not declare — also when
the factory catches that error. `run_start` names the active Profile,
`agent_spawn` names it with every Role the factory read, and both Run tree
headers show `profile: <id>` after the program name.

### Choose a Profile

A fresh Run takes the first of these that gives an id: the launch id
(`--profile <id>`, or `yaag_run({ profile })`), then the `profile` key of the
[configuration](configuration.md#supported-fields) (the last layer wins, and `--no-config`
drops the discovered layers), then `defaultProfile`, else the first registered
Profile.

- A launch id that names no Profile, and a launch id given to a program that
  declares no Profiles, stop the Run with `PROFILE_NOT_FOUND` before it starts.
- A configuration id is ignored by a program that declares no Profiles, and
  fails a profiled program when it names no Profile of it.

The flag is in [the CLI page](cli.md#usage), the key in
[the configuration page](configuration.md#supported-fields).

A resume keeps the Profile of the Run it continues. `--profile` on the resume
replaces it, and the republished Checkpoint stores the new id. A Role whose
binding no longer reaches the recorded model diverges at that Agent's spawn: it
starts live and replays none of its Asks, while an Agent the new binding still
reaches replays as before. `--replay` is strict: a Profile that differs from the
Cassette stops the Run with `REPLAY_DIVERGED`.
