Skip to content

Structure

Stability: stable.

A stage is one step in a flow. A flow is a numbered folder of stages, and an assembly is the folder that holds the flows.

A stage is a box with one input and one output. Inside are the parts this document specifies — a before hook, the agent loop, three checks, and a success or failure hook — and every one of them is optional except the agent loop.

A stage is a single file or a folder.

01-name.md is a stage defined entirely by one markdown file: frontmatter for its configuration, body for its prompt — fenced frontmatter and body like any sentinel, even when the frontmatter is empty (invariant 42: one grammar, one parser).

01-name/ is a stage that needs files of its own. A sentinel file inside names the stage’s type; STAGE.md is the sequential one, and carries the frontmatter and body that 01-name.md would have carried. Other sentinels name other types, listed under stage types. A schema, a gate, and hooks all live in the folder, so a stage that needs any of them takes the folder form.

In a sequence, the leading number declares order: stages run from the lowest number to the highest. A number is one or more digits followed by a hyphen, and it is compared as a number, so 9- runs before 10- and leading zeros change nothing. Gaps are fine. Two entries whose numbers are equal is an error. The name after the number is the stage’s identity. Some containers hold stages that do not run in sequence — the branches of a fan-out, the alternatives of a choice — and those are named without a number; the control graph says which.

Nothing invokes a stage by name. A stage runs because the flow reached it.

Beside its sentinel, a stage folder may hold any of the following, and all of them are optional.

File Purpose
schema.json, schema.md the output’s contract and format
gate, or a gate/ folder checks the output must pass
before runs before the agent loop
success runs after the output passes
failure runs after the stage fails
skills/ skills only this stage can see
subflows/ flows only this stage can call

The runtime executes the three hooks (before, success, and failure) and the gate; the operating system decides how to run them. Each needs its executable bit set and a shebang line, or is a compiled binary, so any language will do. A script may carry an extension naming its language — gate.sh, gate.py, gate.rb, gate.ts — or none at all. The extension is for whoever reads the folder; the runtime ignores it.

A folder holds one file of each kind. Two files whose name before the first dot is before is an error, as is more than one schema, as is a gate file beside a gate/ folder. A schema’s extension is the exception to the rule above: it is not decorative, and it chooses the output’s format.

The markdown body is the prompt — the instruction the agent receives. The frontmatter is the configuration, and it is closed: an unknown key is an error naming the key. A stage whose body is empty is refused (body-missing) — a stage with no instruction asks nothing, and an agent with no instruction is a mistake, not a stage. A body is decoded as UTF-8, and bytes that are not UTF-8 become replacement characters — deliberately: the body is prompt text, the assembly can honestly run, and the author’s garbage reaches the agent visibly rather than refusing a runnable assembly.

intelligence: default
timeout: 3600
retries: 2
workdir: ./investigator

Every inherited key falls back to the assembly’s when a stage does not set it. intelligence selects one complete bundle defined by the home. timeout is the agent loop’s budget in seconds, 3600 by default, and it covers every round of the agent’s work rather than each one separately — a held agent is still on the clock it started with, and only the agent’s own time spends it. Checks and hooks run on clocks of their own and never spend the agent’s (invariant 22). An agent that crosses it is stopped, and the stage fails. retries bounds how many times a failing check may send the agent back, and defaults to 2. A refusal or reported fault skips checks and does not spend a retry (control tools).

None of these reaches the agent. Which model is running, how long it has, and how many retries remain are the runtime’s business — an agent told its budget paces itself against the budget instead of the work.

local-context retains its current meaning, deciding for one stage what $PWD’s own context does (invocation). The stage-only workdir also retains its meaning below. Neither changes the selected intelligence bundle.

workdir is different from those inherited options. It belongs only to a STAGE, and names that stage’s working directory relative to the root workspace selected by bot run start --in. It is an explicit path, not a name inferred from the stage folder. Absolute paths, .. paths, and . are refused (value-invalid), and a path that is not already a directory is refused (path-missing) before provider work starts. Omitting it keeps the flow’s current working directory, which is the root workspace for a top-level flow and the calling stage’s $PWD for a subflow.

The workspace is caller-owned. BotAssembly does not create, empty, retain, or remove a stage’s workdir; it only starts that stage’s agent, hooks, and gates there. Runtime-owned input, output, skills, and scratch keep their existing lifetimes outside it (slots).

These rules do not change inside containers. In a PARALLEL, each branch stage resolves its own authored workdir; branches that author the same directory, or omit the field, share it. Concurrent writes to a shared directory are the author’s responsibility. Every repeat of a LOOP stage uses the same authored directory, so files left by an earlier repeat remain visible to later repeats; its scratch and session identity remain repeat-specific. A subflow called from a stage inside a container inherits that stage’s resolved directory, just as a linear subflow does.

The inherited option keys are legal in every sentinel, and a container’s apply to everything inside it (the control graph). Each container adds its own — repeat on a LOOP.md, width on a PARALLEL.md — and nothing else is accepted there. workdir is the stage-only exception.

Frontmatter carries only what placement cannot say. A stage’s position, its order, and the folder that holds it are read from the filesystem, never restated as keys.

The agent reaches everything through slots — environment variables naming what it needs, never paths it had to find.

$INPUT is a directory holding one file per source. A stage in a sequence has one source, so its $INPUT holds one file, named after the stage that wrote it: after 02-design.md, $INPUT/design.txt. A stage following a fan-out has several, each named after the branch that produced it. Nothing is ever merged; the filenames are the namespace.

$OUTPUT is one file. The stage’s output is what the agent writes there and nothing else. The schema chooses the format, and therefore the extension. A stage with no schema writes text — whatever the agent produced, as it stands — and its output is named .txt.

A schema is a file, so a stage that wants one takes the folder form. A stage written as a single 01-name.md always produces .txt.

$OUTPUT does not sit under $PWD. The caller-owned tree may be shared — other runs and other branches may be in it at the same time — so the one file that has to survive the stage lives somewhere the runtime owns.

The runtime names the input files in the prompt and states what the schema requires. How those facts are worded is prompt construction.

A stage’s work is an agent loop: the model responds, calls a tool, reads the result, calls another, and carries on until it stops calling tools. One stage is one agent loop, running in one session — the log of everything the model has said and done. The session starts fresh at every stage. What reaches a stage from earlier stages is their outputs, never their sessions.

The loop ends when the agent stops and the stage’s checks let it go. A failing check does not start the agent over: the agent has not left, its session is intact, and the reason it was held goes into that session as one more thing it has read.

An agent that cannot do the job says so instead. It calls the refusal tool with a reason, and the stage ends there: no checks run, because there is nothing to check, and no retries are spent, because the agent is not asking for another try. The stage fails, the reason is recorded, and the flow stops. A refusal is a result: it says in the agent’s own words what stopped the work, which is the most useful thing a failed stage can leave behind.

Three checks stand between the agent stopping and the stage ending, and each is optional (gating).

Order Check Judges
1 the checklist what the agent did
2 the schema the shape of the output
3 the gate whether the work is good

An agent that stopped without writing $OUTPUT is caught before any of them.

The first ordinary failure ends the round and sends the agent back into its own session with the reason. retries bounds how many send-backs a stage allows, whichever check caused them: at the default of 2, three rounds at most, and the stage fails when the last one does. A gate exiting 75 with nonempty captured output is an external blocker instead: it ends the stage immediately with exit 1, cause blocked, and its output as the reason, without a send-back or retry.

Three hooks bracket the agent loop (hooks). Each runs with the same slots in its environment as the agent, and changes things by writing files.

Hook Runs May change
before before the agent loop $INPUT
success after the output passes every check nothing
failure after the stage fails nothing

So before rewrites what the agent sees; success reads and publishes what passed — the output is no longer anyone’s to change (hooks).

A hook’s own exit code counts. before exiting non-zero fails the stage before the agent has run at all, and the record names before as what failed. success exiting non-zero fails a stage whose output had already passed its checks. failure runs whenever a stage fails, including that first case, where there is no output for it to look at; it runs after the fact. Its exit, timeout, inability to execute, output overflow, or hash-recheck drift is diagnostic evidence only and cannot replace the stage’s existing ending.

A hook runs once per stage. A held-back agent is still inside the same stage, so being sent back by a check does not re-run before.

flowchart TD
input([input]) --> before[before]
before --> agent[agent loop]
agent -- refuses --> failure[failure]
agent -- stops --> checklist{checklist}
checklist -- unfinished --> retries{retries left}
checklist -- accounted for --> schema{schema}
schema -- invalid --> retries
schema -- valid --> gate{gate}
gate -- 75 with output --> failure
gate -- other non-zero --> retries
gate -- zero --> success[success]
retries -- yes --> agent
retries -- no --> failure
success --> output([output])
failure --> stop([flow stops])
  1. before runs. What it writes is the agent loop’s input.
  2. The agent works until it stops, or until it refuses.
  3. The checklist, the schema, and the gate run in that order. The first ordinary failure ends the round; a gate exit 75 with nonempty output is an external blocker and fails the stage immediately.
  4. An ordinary failure puts its reason into the agent’s session and sends the agent back to 2, without re-running before. When the retries run out, the stage fails.
  5. A refusal skips 3 and 4 entirely and fails the stage.
  6. success or failure runs, according to the outcome.

Any step the stage did not declare is skipped.

A stage exits with one of these codes.

Code Meaning
0 success
1 failure — a check said no, the agent refused or ran out its clock, a before/success hook ran cleanly and exited non-zero, or a gate reported an external blocker: the stage working as designed, the work rejected or blocked
2 the run was found impossible while the stage ran — a gate or before/success hook that could not execute, hung, or broke a passed output, or the machinery beneath the run failing; failure-hook breakage is recorded diagnostic evidence and keeps the prior failed ending
128+n killed by signal n: 130 is Ctrl-C, 143 is terminated

A stage malformed on disk never reaches this table. The whole assembly is read and checked before anything runs, so a stage that would not have parsed simply never runs, and the run itself exits 2. The 2 above is the same fault found late — the folder was well formed and one of its executables was not (the runtime).

A stage that exits non-zero stops the flow: later stages do not run, and the flow exits with that same code.

A stage folder holds exactly one sentinel file, and the sentinel names the stage’s type. Zero sentinels, or two, is an error. All sentinel files are capitalized.

Sentinel Type
STAGE.md runs once, in its numbered position
LOOP.md repeats its contents
CHOOSE.md selects one of several alternatives and runs it
PARALLEL.md runs several branches at once
FANOUT.md runs one subflow per checked list item (fan-out, provisional)

DESCEND.md types a flow rather than a stage, so it never stands in a stage folder (descend). Every other sentinel is in the table.

Stage in this document means the first row, in either form: a folder typed by STAGE.md, or a single 01-name.md. Those run an agent and produce an output, and everything above this section is about them.

The rest are containers. A container arranges stages and passes along what they produced. It has no hooks and no checks, because it produces no output of its own to check (the control graph).

What each container decides, and how, is the control graph.

Stability: stable.

A flow is a procedure: do this, then this, then this. It is a folder under the assembly’s flows/ directory, typed by a FLOW.md file, holding numbered stages.

flows/
review/
FLOW.md
01-read.md
02-analyze/
STAGE.md
schema.json
gate.sh
03-report.md

Stages run from the lowest number to the highest. Nothing else decides the order, and nothing inside a stage names what comes next (the control graph). A sequence number is at most nine digits — enough for any flow, and short enough that every reader orders them the same way (number-invalid).

Each stage’s output becomes the next stage’s input. In slot terms, what 02-analyze wrote to $OUTPUT is what 03-report finds in $INPUT, under the name analyze (slots).

The first stage’s $INPUT holds the run’s request. The flow’s output is the output of its last stage.

The flow’s exit code is the exit code of the first stage that failed, or zero if none did. A stage that fails stops the flow: the stages after it do not run.

FLOW.md is frontmatter followed by an optional procedure body. The body explains work shared by every stage in the flow, and tells each fresh context its enclosing structure and static root step. A missing or whitespace-only body is silent — it adds nothing to a prompt. The fences are still written, because a sentinel is frontmatter and body everywhere (invariant 42):

---
description: Review a change and report what is wrong with it
tmp: flow
---
Read the change, verify each finding, then write the report.

description is required and is one line. It is what a routing decision reads, so it should say what the flow is for rather than how it works; it is not prompt content. The procedure describes the whole flow, not the instruction for one stage.

tmp is stage or flow, and it says whether every stage gets its own scratch directory or they share one for the whole flow (slots). It is stage unless this says otherwise.

Any of the assembly’s authorable defaults — timeout, retries, local-context, and intelligence — may be set here, and every stage in the flow inherits them unless it sets its own (the assembly).

A run that names a flow runs that flow. A run that names none runs the assembly itself: one agent with every flow and every root subflow in scope, calling whichever fit the request and answering with what comes back (running the assembly).

A skills/ directory in the flow folder is visible to every stage in that flow and to no stage outside it. This is the middle of the five skill scopes (skills).

A subflows/ directory in the flow folder holds flows that every stage in this flow can call, and no stage outside it. This is the middle of the three subflow scopes (subflows).

Hooks, schemas, gates, and checklists belong to stages (gating). A flow that wants its input normalized or its result published says so in the stage at that end of the sequence, which is where the work is.

Stability: stable.

An assembly is a folder holding ASSEMBLY.md, a flows/ directory, and optionally skills/ and subflows/.

review-bot/
ASSEMBLY.md
flows/
review/
FLOW.md
01-read.md
skills/
house-style/
SKILL.md

An assembly holds ASSEMBLY.md and flows/; without either it is not one. By default an assembly holds no other visible root entry. It may declare opaque top-level folders as described below. An ASSEMBLY.md anywhere in the grammar’s tree below the root refuses at check. It is referenced by name — resolved in the home — or by an explicit path, and no other way. There is no search of the current directory, no layering, no shadowing, and no consent step: naming a folder is the act of choosing to run it.

The frontmatter carries the defaults every stage inherits. An assembly’s name is its folder’s path under the home’s assemblies/ — like every name in this format it comes from the filesystem, and nothing inside the folder restates it (invocation).

intelligence: default
timeout: 3600
retries: 2
Key Rule
intelligence optional; names one complete choice the home defines
timeout 3600 seconds
retries 2
local-context one of ignore, announce, use; ignore
slots optional; names and describes the slots this assembly declares
tmp-max-bytes optional positive safe integer; the live $TMP ceiling, one GiB (1024 ** 3) by default
strict optional boolean; whether unknown visible root entries refuse; true by default
folders optional YAML list of opaque top-level folder names

Each folders item is a nonempty, non-absolute single path component. It is not ., .., and contains neither / nor NUL. The reserved names ASSEMBLY.md, flows, skills, subflows, README.md, LICENSE, and gate cannot be declared. A declaration need not exist; when it does, its root entry must be a real directory, not a file or symbolic link. Duplicate declarations have no additional effect.

A declared folder is opaque. Bot establishes only that its root is a real directory, then does not enumerate, validate, execute, or capture its contents. The declaration applies to that one root component, not recursively within folders the grammar owns. Its contents do not contribute to procedure identity. Installation and update may still carry the folder as transport data.

A copied installation omits every source entry whose basename starts with .. Bot writes its own root .bot-source after the copy. A linked assembly keeps its source tree unchanged.

With strict: true, any other visible unknown root entry is refused as before. With strict: false, any other visible unknown root is likewise opaque and excluded from procedure identity. Grammar-owned roots are always read and validated. Hidden entries keep their existing treatment. Invalid policy fields grant no omission, preserving strict validation.

An authored rung selects at most one intelligence name. If the assembly does not select one, model choice follows the existing precedence rules; every flow and stage that does not override the assembly inherits its selection (invocation). Other unset assembly options fall back to the home or built-in defaults by those same rules.

The body is the assembly’s purpose statement, and it enters the context of every stage (prompt construction).

HTML comments are author-to-human and are stripped from every body before it reaches an agent — here, in a stage, in a LOOP.md, in a CHOOSE.md. Files on disk are never rewritten. A body that is nothing but comments is an empty body.

A skills/ directory at the assembly root is visible to every flow and every stage. This is the widest of the five skill scopes, and the one to use least (skills).

A subflows/ directory at the assembly root holds flows every stage can call. This is the widest of the three subflow scopes, and the same advice applies: a subflow belongs at the narrowest scope where it is useful (subflows).

The manifest is read before descendants so its top-level traversal policy can be applied. Every non-opaque entry is either understood or refused, and a refusal names the file, directory, or key at fault. A malformed assembly exits 2 and writes no record (the runtime).

Stability: stable.

The home is the directory a runtime keeps everything durable in; scratch lives in a cache the runtime owns (slots).

$BOT_HOME/
installation.json
config.yaml
assemblies/
review/
ASSEMBLY.md
flows/
runs/
2026-07-30T14-22-08-a3f9/

$BOT_HOME if it is set, otherwise ${XDG_DATA_HOME:-~/.local/share}/bot. A --home given on the command line names the home directly and wins over both.

A first bot run start creates the home’s installation identity before run birth when the record is absent. A first bot run resume does the same after it accepts the donor and before it creates the new run. Concurrent first runs use the one identity that wins atomic publication. A run leaves an existing valid identity unchanged. An invalid record refuses the run without repair before provider contact, run-directory creation, run_start, or id-file publication.

bot home show --home DIR reads the installation identity without mutation. It requires the explicit option and ignores BOT_HOME and the platform default. Markdown is the default. --json and -j return a newline-terminated version-1 bot.home.show document. The data.paths object reports config, runs, assemblies, installation, cache, and piAuth. Each entry carries the absolute path Bot resolved and whether it exists at the time of the reading. The Pi session directory is not reported because Bot does not resolve it. Markdown reports the same six paths and standings after Initialized. Each result occupies at most 65,536 UTF-8 bytes. An absent show succeeds with initialized: false and creates nothing.

installation.json is an owner-only (0600) regular file in an owner-only (0700) real home directory. It contains one canonical newline-terminated version-1 bot.installation object of at most 1,024 bytes with one lowercase UUID version 4 identity. Bot refuses malformed, linked, oversized, non-private, wrong-owner, changed, or unsupported records without repair. Bot resolves the real parent and uses canonical pathnames for safe filesystem operations. It opens final objects without following links, compares pathname and descriptor identities, and revalidates the parent and home before and after reads and mutations. A missing home or record produces the absent reading only after the containing objects still validate. Bot detects pathname replacement that these checks observe. The local same-Unix-account trust model excludes an actor that races replacement of an intermediate ancestor. Initialization synchronizes the held parent before it publishes a complete candidate through create-if-absent linking. It then removes its exact private temporary name and synchronizes the held home. Concurrent initializers return the published winner. A non-EEXIST link rejection is retryable when finalization succeeds and never claims possible publication. Cleanup, synchronization, and close failures return an integrity failure. They retain the publication-may-have-completed diagnostic only after a successful link. Moving a valid home preserves the identity. A copy preserves it only when bytes, ownership, and modes remain intact. Bot provides no reset command.

That is the whole of the resolution, and the omissions in it are deliberate. Runs are state and config.yaml is configuration, so a strict reading of the XDG directory specification would put them under $XDG_STATE_HOME and $XDG_CONFIG_HOME; they live in the home instead. The home is ONE folder holding everything bot owns — what a person backs up, copies to another machine, points a second bot at, or deletes to be rid of it — and three directories that must be kept in step are three ways for a home to be half there. --home names all of it or none of it, which is what makes a throwaway home a single argument. Three things are NOT in it. Scratch is out for a reason of its own: it is a cache, and no slot value may disclose where runs live (slots). Credentials are out for the opposite reason to everything that is in — a login belongs to the operator, where a home is a workspace, and repeating a login per project is not a thing a person should have to do. They live in auth.json under Pi’s resolved agent directory, whose default is ~/.pi/agent/auth.json, with the directory at mode 0700 and the file at mode 0600; --home does not move them and no bot-named variable points at them (authentication).

assemblies/ holds assemblies by name. A name is a relative path under this directory — review, or team/review — and that is the whole of the registry: no index file to keep in step, no duplicate-name validation, nothing to drift (the assembly, invocation).

An entry standing at an assembly’s name under assemblies/ may be a symbolic link to a directory; a nested name makes its parent directories real, and the link stands at the leaf. The link is the editable install — deleting it is the uninstall, and the tree it points at is never touched — and everything inside the resolved assembly keeps the symlink ban (the graph). What ran is still answerable, because the record hashes the bytes it read, not the path it read them through (the record). The commands that make and unmake these entries are management.

runs/ holds one directory per run — its record and its sessions (the record, the session). This is data with a retention policy, where an assembly is source somebody wrote. A run in flight also has a lock beside its directory, named for it; the lock is the runtime’s rather than the run’s, and it leaves when the run does (the runtime).

config.yaml holds loose non-model defaults and the named model choices a run can use.

timeout: 3600
retries: 2
local-context: ignore
intelligences:
default:
provider: anthropic
model: claude-opus-5
reasoning: medium
coder-hard:
provider: anthropic
model: claude-fable-5
reasoning: xhigh

It is a strict plain YAML file. Its loose keys are timeout, retries, and local-context; model choice is present only in intelligences. Retired loose model-choice keys and unknown keys are refused (key-unknown).

intelligences is a flat table of complete model choices by name. Every row requires model and reasoning (key-missing); provider is optional, and any other bundle key is refused (key-unknown). Nothing in a bundle is a credential.

The name is an option at the command, task, stage, container, flow, or assembly rung. The nearest name supplies provider, model, and reasoning as one bundle. When none is named, an executing agent looks up the reserved name default. That implicit name and its bundle are stamped home; an explicitly authored intelligence: default keeps its authored rung. A missing row is refused as intelligence-unresolved only when an agent needs it.

A providerless bundle uses normal model lookup. If more than one provider in the catalog offers the model, the run is refused as model-unresolved naming the candidates. If the catalog holds no model of that name, the run is refused as model-unresolved naming the catalog it read and nothing beyond it. If a provider holds the model and the runtime can see no credential for that provider, the run is refused as credential-missing before it is born, naming the provider to sign in to (refusals). Every one of these refusals names the model string as authored, the rung it resolved from, what the runtime observed, and one command.

A run snapshots the table at start and records the resolved name and complete bundle without exposing the name in an agent prompt.

The home and each run directory are created owner-only (0700), like ~/.ssh. Nothing inside is made private separately — the directories close the tree — and the contents of a home that already exists are left as they stand. Its own mode is not: a run refuses a pre-existing home that is not a real directory owned by the effective user at exactly 0700, before run birth. The scratch tree is created the same way, at its root: it holds the same prompts, inputs and outputs the run directory holds, so it is closed by the same door (slots).

A home that does not exist is an empty rung, not an error. Nothing resolves from it, and nothing complains until a run needs what only it could have supplied.

The home is the bottom rung. Every value resolves from the most specific place that sets it (invocation):

command line → task file → stage → container → flow → assembly → home → built-in default

The task-file rung is defined in Invocation.

An assembly names an intelligence, never a literal model. The home maps that portable name to local machinery. An omitted name uses the reserved default row only when an agent needs a model choice.

No slot points into the home. The agent is not told where assemblies live, where its own assembly lives, or that other assemblies exist at all (invariants). A stage’s input, output, scratch, and skills are named for it, and the tree it works in is somewhere else entirely (slots). A caller’s $BOT_HOME is the runtime’s to read and never the agent’s to see: it is the runtime’s own bot-named variable scrubbed from every stage’s environment. Provider credential environment names consumed by the parent are scrubbed too, and a stage’s shell holds that environment whole — what the runtime composed, with nothing of the runtime’s own process beneath it.

A ~ at the front of a path an agent hands a file tool is refused for the same reason: it is shorthand for a directory the agent was never told about. That is obscurity and not prevention, like everything else here (invariants) — a path written out in full still reaches wherever the operating system allows.