Running
The runtime
Section titled “The runtime”Stability: stable.
A runtime is a program that takes an assembly and a request and runs it. This is the contract every runtime meets, whatever language it is written in and however many model providers it supports.
It is an ordinary command-line program. It reads input, writes output, exits with a code. It executes the folder as written and never modifies files in the assembly.
Two runtimes given the same assembly resolve it identically: the same stages, the same order, the same options from the same rungs, the same refusals. What happens once a model is running depends on the model, so a run is not reproducible — the reading of the folder is (conformance).
Command-line contract
Section titled “Command-line contract”The maintained runtime checks this command-line behavior on native Linux and macOS. WSL follows the Linux path. Native Windows refuses every invocation before command work and directs the operator to WSL. Bot depends on POSIX shell launchers, executable bits, owner and mode checks, Unix signals, and process groups. On admitted platforms, the runtime preserves request and result bytes, separates standard output from standard error, observes standard-output backpressure, treats an early-closing reader as quiet success, reports other delivery failures through its exit status, and retains signal exit meanings. This contract does not claim formal POSIX certification. A runtime refuses every platform outside the two it admits, not native Windows alone.
Release status, not contract. The final release candidate still requires a clean-clone WSL qualification. That task is open.
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
0 |
success |
1 |
failure — it ran and did not pass |
2 |
the run is impossible — the assembly, the invocation, or the machinery beneath the run is wrong |
3 |
a continuation conflicts with the state it continues |
4 |
something the command depends on failed; trying again may work |
5 |
retained state or a result is unsafe, corrupt, or invalid |
126 |
reserved: found but not executable |
127 |
reserved: not found |
128+n |
killed by signal n |
A stage’s exit code becomes its flow’s, which becomes the run’s.
3, 4, and 5 belong to the commands that read and manage retained state,
and to a run that fails before it is born. A cursor whose retained state moved
under it exits 3. A dependency the command needs — the home’s filesystem, a
provider catalog, a lock another process holds — exits 4, and its structured
error marks itself retryable. Retained state or a result that is unsafe,
corrupt, or invalid exits 5. Each of the three carries the JSON error
envelope in structured mode (inspection).
A run that reaches run_start ends in 0, 1, 2, or 128+n; a structured
result too large to write replaces that ending with 5.
2 is the run being impossible, whenever that is discovered. Almost always that
is before anything runs — the assembly or the invocation is wrong — and then
there is no record. A gate or before/success hook that cannot be executed, or that runs out of
time, is the same fault found late, and a failure in the machinery beneath the
run — cause fault — lands here too: the run exits 2 and the record it had
already started says where (causes). A failure
hook’s corresponding breakage is diagnostic evidence only and keeps the stage’s
prior failed ending.
POSIX gives 126 and 127 to the shell, so a gate with a bad shebang or a
missing interpreter exits 127 without ever running. Read as a verdict, that
would send an agent back to fix work that was fine. A runtime treats 126 and
127 from a gate or before/success hook as a broken assembly, not as a
decision, and fails the run. A failure hook’s corresponding breakage remains
recorded diagnostic evidence and keeps the prior failed ending.
A gate child that exits 75 with nonempty combined captured output instead reports an external blocker. Its stage and run exit 1 with cause blocked and the gate’s bounded terminal view as their reason; they do not publish exit 75. The exact output remains in the check capture. The output is kept unsealed and judged, the failure hook runs, and the flow stops. A silent 75 remains an ordinary failing gate. This verdict belongs only to captured, hashed executable gates, never to hooks, other checks, control tools, provider or model output, or model prose.
Everything distinguishes “we never started” from “it ran and failed”, because those call for different responses from whatever invoked the runtime.
Every new root or resumed run establishes one valid home installation identity before run birth. A missing record makes the runtime initialize the home through atomic create-if-absent publication. Concurrent first runs use the published winner. An existing valid identity remains unchanged. An invalid record refuses the run before provider contact, run-name reservation, run-directory creation, run_start, or id-file publication. The runtime passes that one value to every subflow child without rereading the home.
An unexpected failure after Bot creates a run directory but before it finishes writing run_start is an unborn-run fault. Its one-line reason names the failed preparation operation and occupies at most 2,048 UTF-8 bytes. Expected refusals and preparation faults use one cleanup result path. Bot attempts to remove the directory while the run-name reservation remains held. A removal failure replaces the earlier result with a bounded cleanup fault and does not claim that cleanup succeeded. Bot then attempts to release the reservation. Failures after run_start keep the ordinary started-run behavior.
Streams
Section titled “Streams”Standard input is the request when the request arrives that way (invocation). On success the run’s output goes to standard output; diagnostics always go to standard error. A run whose output is piped into another program prints nothing else on that stream. A run that ends nonzero names its cause on standard error — and the reason behind it, when there is one — so standard output carries only output. That reason is spelled the way a reading spells one: the run’s own text cannot repaint the line it is printed on. While standard error is a terminal, a run names each stage there as that stage starts: a display for whoever is watching, so what a capture receives is unchanged and the record holds no line of it.
Every ordinary standard-output write settles in order before explicit process exit. A successful command keeps exit 0 after EPIPE. Another standard-output delivery failure makes a successful command exit nonzero and adds one bounded inert diagnostic to standard error. A command that already has a nonzero status keeps that status under every delivery outcome; EPIPE adds no diagnostic. Raw inspection keeps its stronger fixed-extent streaming contract.
The settlement path observes backpressure in order, but the synchronous command boundary and the command that produces ordinary output may queue or materialize results before delivery. Bot does not promise bounded memory for generated ordinary output. Raw inspection separately streams one fixed held extent with bounded memory.
bot run start and bot run resume use the existing start and resume runtimes. Human mode keeps those bytes and diagnostics. Structured mode writes one newline-terminated version-1 bot.run.result document to standard output after every started run and writes no progress or terminal diagnostic. The document names the run, installation identity, start, completion state, exit, cause, recorded ending, terminal stage, bounded reason, correlation, and accepted output only when each fact exists. The result projects the installation identity from the successfully written run_start object. It does not reread the home. A resumed result also names its donor and always reports the count of durably appended carried stages. It includes the ordered identities only when the complete result fits and otherwise states that it omitted them. A nonzero started run remains a result and preserves its exit. A failure before run_start uses the common error document on standard error and creates no durable result. --correlation VALUE supplies the correlation the result carries. The option is accepted once, its value is nonempty, and it occupies at most 256 UTF-8 bytes; a repeated, missing, empty, or oversized value refuses the invocation before the run begins.
The complete structured result occupies at most 65,536 bytes. An accepted output appears inline as complete UTF-8 or base64 only when the whole document fits. Otherwise the result carries only the output’s record-relative path, extension, byte count, and SHA-256. A reason carries its original byte count, a truncation fact, and a UTF-8-safe prefix of at most 2,048 bytes. A writer failure may name a started run with complete: false; it supplies no invented ending time.
Run id file
Section titled “Run id file”The option --id-file PATH is accepted by bot run start and bot run resume. It writes the run’s id after run_start and before any stage event or work. The option does not change the command’s existing standard output. Inability to publish a requested id is a machinery fault: the run ends with exit 2 before any stage executes.
The environment
Section titled “The environment”Slots are environment variables — $INPUT, $OUTPUT, $TMP, $SKILLS,
$SUBFLOWS, $PWD. The runtime exports them into the agent’s environment and
into every process the agent starts, and the shell expands them like any other
variable (slots). TMPDIR is set beside $TMP for every process
the run starts, over any caller value, so a program that reads the platform
convention writes into the stage’s scratch (invariant 43).
The agent’s $TMP and a hook’s or a gate’s $TMP name the same directory
through two different strings. A hook and a gate receive the backing path. The
agent and every process it starts receive a short handle the runtime made for
that directory, which is what the agent’s own tools resolve. The record’s
slots.tmp holds the backing path, not the handle
(the record, $TMP).
The rest of the environment passes through: an agent, hook, or gate sees the
caller’s environment beneath the slots — that is how a script finds PATH —
and the slots unconditionally overwrite anything of the same name
(invariant 43). Provider credential environment names consumed
by the parent and BOT_HOME, the runtime’s own bot-named variable, are
scrubbed before anything agent-side runs (the home). The names this
runtime scrubs are listed in slots. A name outside
that registry is not a name the runtime can recognize, and it survives into
every stage. Every process
the run starts also receives $BOT_RUN_ID, unconditionally replacing any
caller value with the basename of the run’s directory, so its work can name the
run without learning that directory’s path.
Signals
Section titled “Signals”A runtime handles SIGINT, SIGTERM, and SIGHUP. On any of them it stops
starting new work, terminates running children, writes down what it knows,
destroys settled $TMP directories, and exits: 130 for SIGINT, 143 for
SIGTERM, 129 for SIGHUP. “Stops starting
new work” includes hooks: a failure hook does not run for a stage a signal
ended — the run is dying, and the signal events are the story
(invariant 44).
Cancellation is checked between units of work, not only at the end, so a model in the middle of a tool loop stops at the next boundary.
A stage cancelled between its agent stopping and its checks finishing is
recorded with cause signal: its output is kept, unsealed, and the record
claims nothing judged it. Nothing is discarded and nothing is called a failure
that was never checked (invariant 17).
SIGKILL cannot be handled. A run killed that way leaves an incomplete record,
and a runtime recognizes an incomplete record as incomplete rather than reading
it as a result.
A dead run is dead (invariant 44). Nothing resumes across the
process: holding an agent happens within a stage within a live run, and
invoking again after a signal is a new run. A caller may start a new run with bot run resume RUN from independently verified
sealed stages of a prior run; the dead process and its session are never
resumed.
Liveness
Section titled “Liveness”A run holds a lock for as long as it is running, and the lock says so by being
refreshed: its timestamp moves while the process lives, and a process that
stopped stops moving it. That is how status reads running versus crashed
and management makes its conservative checks, which the record alone cannot
do — a killed run and a working one look identical from a last line that is
not run_end (inspection).
The lock sits beside the run’s directory rather than inside it, because the
directory holds what the record specifies and nothing else, and a run is
archivable as a unit (the record). A
handled signal releases it on the way out. SIGKILL releases nothing, and
that is the point: the lock stops being refreshed and goes stale within
seconds, making the run read as crashed.
The judgment is a comparison of timestamps, so it can be wrong for a moment. A machine asleep past the stale window wakes with every live run briefly reading as crashed, until the next refresh. A stale timestamp does not prove death. Management treats a physical run lock as a live claim. The window is seconds, and the race is stated here rather than engineered around.
Nothing bounds a run as a whole — no deadline, no cost budget, no disk quota. A
run ends when its stages do, and supervising it is the operator’s job. One
ceiling stands before a run: a request of more than 4,194,304 bytes is refused
with request-invalid naming the request, whether it arrived as an argument, a
task document, that document’s retained body, standard input, or a resumed
donor (refusals).
An assembly’s tmp-max-bytes is a ceiling on the readable bytes below a live
stage’s $TMP, defaulting to one GiB (1024 ** 3). While the stage gate is
active, the runtime samples the temporary directory’s backing path. The Bot
runtime samples every 250 milliseconds and reschedules a sample it could not
read. A sample
strictly above the ceiling faults the stage and run with exit 2; an unreadable
or vanished subtree contributes zero and does not stop its readable siblings
from being counted. This is sampled enforcement, not a privileged quota or a
byte-exact instantaneous promise, and it applies whether $TMP is stage-local
or flow-shared.
Running a stage
Section titled “Running a stage”- Bind the stage’s slots: a fresh
$INPUTholding one named file per source, an empty$TMP, a path for$OUTPUT, the flattened$SKILLS, and an empty$SUBFLOWSwhen any subflow is in scope. - Run
before, if present. What it writes is the agent loop’s input. - Run the agent loop until the agent stops, refuses, reports a fault, or
crosses the stage’s
timeout— which covers every round of the agent’s work, not each one separately; checks and hooks run on their own clocks (invariant 22). An agent that crosses it is terminated and the stage fails. Termination is a request a provider may ignore: work still unsettled when the stage ends is recorded as unreconciled, neither bounded nor waited for. - Check that
$OUTPUTexists, then check, in order and skipping any that is absent: the checklist, the schema, the gate. The first ordinary failure ends the round; a gate’s evidenced exit75is an external blocker and ends the stage. - On an ordinary failure, put its output into the same session and let the
agent carry on, up to the stage’s
retries. The agent is held, not restarted. - On a refusal or reported fault, skip step 4 entirely and fail the stage. A
refusal exits
1with causerefused; a reported fault exits2with causefault. Neither spends a retry. - After the agent stops and before the stage’s checks run, inspect
$TMP. If it has entries, warn the held agent once that the directory is destroyed when work ends, list the entries largest first, and offerclean-tempor finishing anyway. The warning names$OUTPUTfor output-accounted evidence and$PWDfor later-stage material. It spends no retry and does not clean anything. An empty$TMP, refusal, or signal has no warning; after the warning the next completion may settle with$TMPempty or not. The warning is issued once per stage, on the round that reached the checks, so a round the checks then send back is the round that carried it and the settling round carries none. - Run
successorfailure, according to the outcome. - Seal the output and move on. When a stage settles, destroy its
$TMP; a flow-shared$TMPremains until that flow settles. This cleanup applies to every settled outcome, including a refusal, fault, or handled signal. ASIGKILLcannot settle and leaves its temporary directory behind. A teardown that fails never displaces the settled outcome: the failure is recorded as its owntmp_teardownevent with its reason (the record), and the stage’s ending stands. A stage that did not succeed stops the flow.
The holding in step 5 is the demanding part. A runtime must be able to intercept the agent’s attempt to finish and resume that same session in place, with the check’s output appended. Resuming is not re-running: a runtime that answers a failed check by starting the stage over from the prompt does not implement this specification, because it charges for the work twice and throws away everything the agent learned.
Control tools
Section titled “Control tools”Almost everything about control is placement — the graph is the folder tree, and
no agent has any say in it (the control graph). There are seven
places where the format does want a judgment a model is better at making, and
each is a tool the runtime provides. Six of them are one closed list every
stage draws from. subflow is the seventh and is granted separately, only to a
stage with a subflow in scope, so it never joins that list.
| Tool | Who has it | What it does |
|---|---|---|
| mark | any stage with a checklist | moves one item to done or skipped |
| refuse | every stage | declares the stage cannot be completed |
| fault | every stage, LOOP question, and CHOOSE |
reports that the stage cannot continue |
| continue | the last stage of a LOOP whose body asks |
says whether another repeat is needed |
| select | the agent of a CHOOSE whose body asks |
names one of the options |
| subflow | any stage with a subflow in scope | runs flows and returns their outputs (subflows) |
| clean-temp | every stage and LOOP question |
empties only that stage’s $TMP contents |
That is the whole list, and it is meant to stay short. A tool here is an agent
being asked a question the format cannot answer on its own; anything an author
can express in a folder name does not belong here. When an assembly routes, no
tool is added: the assembly agent uses subflow, with every flow in its scope
(running the assembly).
Each tool’s arguments are fixed, so two runtimes record the same decision the same way:
- mark takes the item’s number — 1-based, in the order the checklist lists
them, so two identically worded items stay distinct — a state,
doneorskipped, required evidence, and forskippeda reason. Evidence is an unpoliced string naming the proof; a missing or empty one rejects the mark. - refuse takes a reason.
- fault takes a reason.
- continue takes an answer,
continueorstop, and a reason. - select takes a name from the list the runtime supplied, and a reason.
- subflow takes a batch of calls (subflows).
- clean-temp takes no arguments and empties only the
$TMPdirectory the runtime attached to the stage.
Every decision that has one is recorded with the reason the agent gave, and a
mark with its evidence; clean-temp records the decision clean and no path
(the record). None of them lets an agent change what
is checking it: it can say it is finished, it can say it cannot finish, and it
cannot touch the checklist, the schema, or the gate.
Refusal deserves its own note. An agent that cannot do the job says so and
leaves. The refusal skips every check, spends no retries, fails the stage, and
stops the flow, and the agent’s own words for what went wrong are what the run
is left holding. It means the same thing wherever it lands: an agent held by a
failing check may refuse instead of trying again, and the agent of a LOOP
asked its question may refuse instead of answering, and in every case the stage
fails with the reason — even one whose checks had already passed.
A reported fault also skips checks and retries, stops the flow, and retains
the agent’s reason in the stage and run endings. It ends with exit 2 and
cause fault, including when it is reported by a LOOP question or CHOOSE.
Container endings the graph cannot produce
Section titled “Container endings the graph cannot produce”Two endings belong to the machinery beneath a container rather than to any
stage in it. A LOOP that ran no repeat exits 2 with cause fault and says
so. A CHOOSE whose chooser named no alternative the runtime is holding does
the same. Neither is an author’s fault an assembly reader could have refused,
and neither is a verdict on the agent’s work.
The agent’s tools
Section titled “The agent’s tools”Every stage model receives read, write, edit, and Bash tools. Direct file tools accept absolute paths. A direct file tool refuses a path that starts with ~ and asks for the path written out, so the caller’s own home is not addressable by shorthand from inside a stage. A shell the agent starts expands ~ itself and is not covered by that refusal. Commands, hooks, gates, aliases, and subprocesses run with the operator’s filesystem and network authority. Bot does not confine them to the working directory.
Bot retains reported direct tool calls in retained model sessions. Bot does not watch the filesystem or claim a complete list of changes. A command, script, alias, subprocess, or outside actor can change files without a distinct reported tool call. An operator must supply operating-system or container containment before running an untrusted assembly or model (invariants).
Model sessions
Section titled “Model sessions”Every stage gets a fresh session. A runtime does not carry conversation from one stage to the next; what reaches a stage from earlier stages is their outputs.
A model turn ends in one of a few conditions, and a runtime handles each distinctly: the model finished, the model asked to call a tool, the model hit its output limit before finishing, the provider failed, or the work was cancelled.
Provider failure is a condition to be inspected, not an exception to be caught. A runtime that assumes success whenever nothing was thrown will record outages as successful work. Hitting the output limit is not a failure either; it is a truncated turn, and a truncated turn is never read as the agent having finished (invariant 47). The runtime continues the agent’s work; how is the runtime’s business, not this specification’s.
A runtime may retry a transient provider failure. Whether it retries, how many
times, and after what delay are the runtime’s business, not this
specification’s. What is required is observable: every runtime-selected retry
is written into the record (the record), retrying never exceeds
the stage’s deadline, and a provider failure that remains fails the stage with
cause fault, never as a verdict on the agent’s work. Its final reason starts with the outer error message. When the error has a direct cause that is a non-array object with a nonempty string message, the reason adds : and that message. A nonempty string code on that same object adds [CODE]. Nested or malformed cause values are ignored. The runtime applies its existing 2,048-byte UTF-8-safe bound after composing the reason and carries the same text in the assistant session, stage_end.reason, and run_end.reason.
A runtime owns the tool loop — taking the model’s request, executing it, returning the result, continuing until the model stops — and records what each turn cost in tokens, attributed to its stage.
What the runtime knows and the agent does not
Section titled “What the runtime knows and the agent does not”The agent reaches everything through slots. It is not told where the assembly lives, where the record lives, where its scratch really is, or where anything checking it lives. It is not told its provider, its model, its timeout, or how many retries remain.
This information hiding directs the agent’s attention and reduces accidental interference with the machinery. It does not restrict what the operating system lets a process reach.
Bot is not a sandbox. Slots do not hide their values from a shell. The retained sessions show only direct tool calls reported by the model harness. They do not watch the filesystem or list every side effect.
Running child processes
Section titled “Running child processes”Hooks and gates are ordinary processes.
- A child gets the stage’s
timeoutas its own budget, counted from when it starts, so it fails the same way every time rather than depending on how long the agent took. - Crossing it terminates the child. A gate or
before/successhook that hangs exits2: the assembly is wrong, never the agent’s work. A failure hook’s timeout, inability to execute, output overflow, or hash-recheck drift remains diagnostic evidence and cannot replace the failed stage’s ending. - Termination is
SIGTERM, thenSIGKILLafter a grace period, applied to the whole process group rather than just the process that was spawned. This runtime waits 250 milliseconds between the two. - A child’s captured output is drained after the child exits, because a
descendant can still hold the pipe. The Bot runtime waits one second of quiet
and five seconds in total, then cuts the capture. A gate or
before/successhook whose output never closed exits2with causefault, naming the file that did not close its captured output after exiting. - A runtime tracks children it detached and cleans them up on exit. It records
each detached process group in the run directory before starting it, and
removes that evidence only after the normal termination sweep has accounted
for the group. A
SIGKILLcan leave that evidence for a laterbusyreader; a malformed or in-progress entry is conservatively busy. A descendant that escapes its process group withsetsidor a double fork is outside ordinary hook and gate containment. - A gate or
before/successhook’s exit code is its verdict, except for126and127; only a gate’s evidenced75is the external-blocker verdict. Those failures retain terminal force; a failure hook’s exit is diagnostic evidence only. - Output is captured as bytes, never as decoded text. Decoding truncates binary output and splits multi-byte characters at chunk boundaries.
- Output is bounded at 16 MiB, stdout and stderr together; crossing it
terminates the child. A gate or
before/successhook exits2, for the reason a hang does; a failure hook keeps the prior failed ending. The capture keeps what arrived before the cut. - A child’s stdin is at end of file from the moment it starts, so a child blocking on input terminates rather than hanging. A runtime passes a child no input; a child that reads its own input reads nothing. A child that never reads does not fault the run.
Input and output
Section titled “Input and output”$INPUT is a directory holding one named file per source; $OUTPUT is one file
(slots). Bytes are preserved exactly.
Once an output has passed its checks and success has had its say, the runtime
seals it: it copies the bytes into the run’s directory and reads the next stage’s
input from that copy. Whatever happens to the original afterward changes
nothing, and the copy is what the record holds
(the record).
The record
Section titled “The record”Every run that starts produces a record, written by appending. Facts land as they happen and are never edited, and any summary is computed from those facts, so a summary cannot contradict the events (the record).
Refusing an assembly
Section titled “Refusing an assembly”A runtime validates the whole assembly before running any of it. A malformed
assembly is a usage error: exit 2, no record, because nothing ran.
A refusal carries a code, the path at fault, and a sentence (refusals). Every entry in an assembly folder is either understood or refused, and nothing is silently ignored.
Identity
Section titled “Identity”Every stage has an identity derived from its position in the folder tree, from what the author wrote rather than from what happened at run time. A stage inside a loop or a branch runs more than once, and each occurrence is distinguishable.
A runtime records the assembly’s content hash, covering every visible file in
it — dot-entries are outside the assembly and outside the hash
(invariant 13). Each
time it runs an executable — a hook, a gate — it also records the
hash of that file’s bytes as they were at that moment. A gate or before or
success hook whose hash disagrees ends the run there: exit 2, with the
record naming the file that changed. Failure-hook hash drift is recorded as
diagnostic evidence and keeps the failed stage’s ending. The assembly may not
change under a run (invariant 14).
Named bounds
Section titled “Named bounds”A runtime is free to choose these numbers; a second implementation reads this list to learn where the Bot runtime put them.
| Bound | Value |
|---|---|
the largest timeout an author may write, in seconds |
2147483 |
| an assembly fetch’s own deadline | 600,000 ms |
| credential-lock attempts, and the sleep between them | 51 attempts, 20 ms |
| provider retries, and the first delay, doubling | 2 retries, 1,000 ms |
| assembly files hashed at once | 8 |
runs a legacy listing shows without --all |
20 |
| the largest page offset a reading accepts | 2147483647 |
| an authentication import’s destination and source lock waits | 30,000 ms, 1,000 ms |
Concurrency
Section titled “Concurrency”Stages in a sequence run one at a time. Branches of a PARALLEL run at once, bounded by that stage’s width (parallel). Items in a FANOUT run their selected subflow at once, bounded by its width (fan-out). Both controls record results in authored name order. The recorded order never implies completion order.
Invocation
Section titled “Invocation”Stability: stable.
Invocation is how a run starts: how the assembly is named, where the request comes from, and how options resolve.
The three ways to give a request
Section titled “The three ways to give a request”bot run start review/change "the auth change in #482 looks wrong"bot run start review/change @tickets/048.mdbot run start review/change < payload.jsonA run names its assembly and its flow, separated by a slash. What follows is
the request: a quoted string is the request itself, and an argument starting
with @ is a task file — the sigil says “the request is in here.” That is
the whole grammar of the argument, and it is why there is no --task option: a
request is an argument, not a modifier.
bot run start accepts -- as the end-of-options marker. Its options come before
the marker; the marker itself is discarded, and the following words fill the
assembly-and-flow and request positions without being read as options. Thus
bot run start --in ../work -- review/change --ticket-482 runs review/change with
the literal request --ticket-482. This grammar belongs to run only;
invocations without the marker behave as before.
All three end in the same place. The request becomes one file in the first
stage’s $INPUT, named request — with the extension of the task file it came
from, or .txt when it came from the command line, from stdin, or from a task
file with no extension — and the
flow’s first stage reads it like any stage reads its input (slots).
The request is bytes, and the runtime does not parse it. If it is JSON, the
first stage runs jq. If it is a ticket with frontmatter, the first stage reads
the frontmatter. This is what keeps piped JSON and markdown-with-frontmatter from
fighting each other: neither is privileged, because neither is interpreted.
bot run start accepts the target, request forms, options, declared slots, and -- boundary described here. Human mode prints the run’s answer. --json or -j returns one bounded structured result after the run settles. --correlation ID adds one nonempty opaque caller value of at most 256 UTF-8 bytes to the initial record and the structured result. Bot does not require uniqueness and gives the value no replay meaning.
The exception is a task file, which is markdown by construction. Its frontmatter
may carry option overrides and is not part of the request; its body is, and that
is what lands in $INPUT. A key in a task file that is not an option is an
error, the same as one in a stage.
Exactly one request arrives. An argument beats the stream: when a string or a
task file is given, stdin is not read at all — so a script that redirects stdin
out of habit changes nothing. Stdin is the request only when no argument was
given, it is not a terminal, and it holds bytes. Two request arguments, or no
request by any of the three ways, is refused (request-invalid) — a run with
nothing to act on has nothing to do.
The complete request occupies at most 4,194,304 bytes. The bound is inclusive and Bot refuses a larger argument or task source before home access or run birth. For a task file, both the complete Markdown source and the retained body after frontmatter parsing, comment removal, malformed-prose replacement, and fenced newline normalization must fit. Standard-input collection stops and refuses as soon as it reads byte 4,194,305. An admitted request keeps its exact bytes under the source’s established parsing rule.
Where the work happens
Section titled “Where the work happens”bot run start review/change --in ../worktrees/482 "the auth change looks wrong"The root directory the flow works in is named by the caller, not inherited from
wherever the runtime happened to be started. It is the default $PWD for every
stage and the base for a stage’s explicit relative workdir
(slots). That is what makes two runs of the same flow against two
worktrees safe to run at once.
Without it, the caller’s own working directory is the root. A named directory
that does not exist is refused (path-missing) — the same code a declared
slot’s or stage working directory’s missing path gets, because the fault and
the fix are the same.
Resuming a run
Section titled “Resuming a run”Current bot run resume
Section titled “Current bot run resume”bot run resume RUN starts a new run from RUN; it never resumes the donor process. RUN is a full run name or an unambiguous prefix in the selected home. Before a new record exists, Bot rejects a missing, ambiguous, live, unreadable, malformed, or unverifiable donor. It derives the assembly, optional flow, and retained request from the donor, verifies the retained bytes, resolves the recorded assembly in the selected home, and checks its current hash against the donor’s recorded hash. The donor and its record never change.
Resume applies the same 4,194,304-byte request limit before creating a new identity, directory, record, or id file. A larger historical donor request remains available through bot run request RUN --raw, but it cannot seed a new run.
The command accepts --home, --in, declared slot paths, --id-file, bounded opaque --correlation metadata, and --json or -j. --correlation enters run_start and the structured result. --json and -j select structured output. Resume adopts the intelligence name the donor’s command rung recorded. It resolves that name through the current home intelligences table and records intelligence, model, reasoning, and provider when the row names one at the command rung. Resume still refuses --intelligence. A donor that recorded no command-rung name leaves intelligence to the current authored configuration, the selected home, and the built-in defaults. Current authored configuration, the selected home, and built-in defaults resolve timeout, retries, and local context again. A carried prefix contains only a contiguous prefix of plain root stages. It stops before the first LOOP, CHOOSE, PARALLEL, FANOUT, or DESCEND; every container and its contents run fresh. Each carried stage is a successful sealed and judged stage with a verified output. Bot copies and hash-checks each output into the new self-contained run and records it as carried.
Human mode writes the run’s accepted output. Structured mode returns the bounded bot.run.result contract with the donor and durable carried-stage count. Complete carried identities appear in order only when the full result fits. A stage counts only after its stage_carried event has been durably appended.
Run id file
Section titled “Run id file”The option --id-file PATH is accepted by bot run start and bot run resume. It writes the id of that run to PATH once the run has started. The option belongs only to run creation: bot assembly check does not consume it, and it is outside the authored option ladder. A relative path resolves from the caller’s working directory. The runtime creates or replaces the named file but does not create a missing parent directory.
Declared slots
Section titled “Declared slots”An assembly that declares slots (slots) is supplied them at invocation, each as a long option named after the slot:
bot run start docs/answer --kb ../knowledge-base "how do refunds work?"Every declared slot must be supplied; a run missing one is refused
(slot-missing), and a supplied path that does not exist is refused
(path-missing). These are options rather than arguments because they modify
where the run looks, not what it is asked — the request is the argument.
Options and where they resolve
Section titled “Options and where they resolve”Loose non-model options use eight rungs, most specific first: command, task,
stage, enclosing containers innermost first, flow, assembly, home, and built-in
default. Those keys are timeout, retries, and local-context.
Model choice has one authored key, intelligence, at six rungs: command, task,
stage, container, flow, and assembly. On the command line it is
--intelligence. The nearest name looks up one complete row in the home’s flat
intelligences table. Provider, model, and required reasoning inherit the
name’s rung. If no rung names one, an executing agent looks up default and
all four resolved values carry the home source. The command rung is run-wide.
It has no per-stage spelling, and the stage rung is the per-stage choice.
A missing intelligence row is refused (intelligence-unresolved) naming the
requested name. A providerless row retains provider ambiguity as
model-unresolved, naming candidate providers. A model the catalog does not
hold is model-unresolved as well, and a model whose provider has no visible
credential is credential-missing, both before the run is born
(refusals). Retired model-choice keys and
long options are key-unknown and point operators to the table and
--intelligence.
timeout defaults to 3600 and is at least 1 and at most 2,147,483; retries
defaults to 2 and is at least 0; local-context defaults to ignore. A
runtime refuses a timeout it cannot honour. Every effective value is recorded
with its source rung.
The command rung is spelled --timeout SECONDS, --retries N,
--local-context MODE, and --intelligence NAME. bot run start accepts all
four, and so does bot assembly check, which resolves the same ladder without
running anything.
The scripted model
Section titled “The scripted model”bot run start also accepts --script PATH. It replaces the resolved model
with a recorded transcript read from that file, so the run reaches no provider,
and run_start says so with model_source: "scripted"
(the record). The option exists to exercise a runtime without
spending a model call. Nothing else about the run changes: the assembly is read
the same way, the checks run, and the record is an ordinary record.
What $PWD’s own context does
Section titled “What $PWD’s own context does”bot run start review/change --local-context use "the auth change looks wrong"A tree being worked in often carries instructions of its own — an AGENTS.md,
a skills/ folder — and whether any of that reaches the agent is the caller’s
decision, not the tree’s. local-context is that decision, and it takes one of
three words:
ignore— the built-in default.$PWDis a path; nothing about its contents enters the prompt.announce— the prompt names what$PWDcarries, its document and each of its skills, name and one line, and injects none of it (prompt construction). Reading is the agent’s own act, with its own tools, from$PWD.use— the document’s body enters the prompt, and$PWD’s skills join$SKILLSas the widest scope (skills).
The document is AGENTS.md, falling back to CLAUDE.md only when AGENTS.md
is absent — one document, never two. The skills are the folders under
$PWD/skills/ and then $PWD/.claude/skills/, the first name winning, each
laid out the way any skill is; a folder without a described SKILL.md is not
one. A value that is none of the three words is refused (value-invalid), the
same as any option’s.
A $PWD carrying no document and no skills announces nothing and injects
nothing under any of the three: absence is an ordinary fact about a workspace,
not a fault. A subflow child resolves the key from its own rungs like any
option (subflows) while defaulting to its parent’s $PWD; an
authored stage workdir selects its own tree. Admitting one tree therefore
admits it only in the stages that actually use it.
What the run keeps
Section titled “What the run keeps”- The request as supplied, byte for byte, before anything read it.
- Which of the three ways it arrived.
- Each stage’s resolved options and their sources.
- The assembly’s identity and content hash.
- Explicit donor provenance when it continued verified sealed work.
Naming the assembly
Section titled “Naming the assembly”The argument before the request is the assembly and the flow, and the split is
read from the filesystem: when the whole argument names a folder holding
ASSEMBLY.md, it is an assembly and the run has no flow
(running the assembly); otherwise the flow is the
last segment, and everything before it is the assembly. A home where both
readings are true — an assembly a/b beside an assembly a that has a flow
b — is refused naming both readings (request-invalid): the runtime does
not guess between two runnable things. An assembly name is a
relative path, resolved under the home’s assemblies/ directory — review and
team/review are both names (the home). One that starts with /,
./, or ../ is a filesystem path instead, and the folder it names is the
assembly:
bot run start review/change "…" # $BOT_HOME/assemblies/review, flow changebot run start team/review/change "…" # $BOT_HOME/assemblies/team/reviewbot run start ./review-bot/change "…" # an explicit path, same flow ruleThere is no search of the current directory, no layering, and no shadowing. Naming a folder is the act of choosing to run it (the assembly).
Running the assembly
Section titled “Running the assembly”bot run start review "what should happen to the auth change in #482?"A run that names no flow runs the assembly itself. One agent — the assembly
agent — receives the body of ASSEMBLY.md as its prompt, the request in its
$INPUT, and the same slots a stage gets, with the assembly’s skills in
$SKILLS. Its scope for the subflow tool is every flow and
every subflow at the assembly root — a flow and a root subflow sharing a name
is refused (input-collision), the fault naming the root subflow — the flow
is the entry point; the subflow is what intruded on the shared namespace. It
reads the request, calls whichever fit, and answers with what comes back — or
answers directly when nothing does. This is the one place flows are callable;
everywhere else a flow is an entry point and only subflows/ are in scope.
The assembly agent has no checklist, schema, or gate — ASSEMBLY.md declares
none — so what it writes to $OUTPUT is the run’s output as it stands. It may
refuse like any agent. In the record its identity is assembly, and its calls
land like any stage’s: one event each, one child run each
(the record).
Prompt construction
Section titled “Prompt construction”Stability: stable.
The prompt is everything the agent is given at the start of a stage. What it contains is a design decision, and so is what it leaves out.
What the agent is told
Section titled “What the agent is told”- Its instruction — the markdown body of its stage, including the
## Checklistheading if there is one (the checklist). - The assembly’s purpose — the body of
ASSEMBLY.md, which enters every stage (the assembly). - Its input — that
$INPUTis a directory, and the name of every file in it. - Its output — that the stage’s output is what it writes to
$OUTPUT, and what the schema requires of it (the schema). - Its other slots —
$PWD,$TMP,$SKILLS, and$SUBFLOWSwhen it has subflows in scope (slots). - Its skills — by name and one-line purpose, with the rest read on demand (skills).
- Its subflows — by name and description, when any are in scope (subflows).
- What
$PWDcarries — nothing at all by default; its document and its skills by name underannounce, and its document’s body underuse(invocation). - Its procedure — the nonblank body of its ordinary
FLOW.md, then an enclosing composite’s kind and authored name when relevant, including a parallel branch or choice alternative, followed by its staticStep N of M.root position. It enters after workspace context and before the stage instruction (the flow). - Its control tools — whichever it has (control tools).
- The choice it is deciding — the body of the
CHOOSE.md, when the stage is a choosing agent (choose). - Applicable prior-attempt evidence on resume — the first fresh plain root stage sees the retained evidence filename under its ordinary input list and a runtime-owned sentence that identifies its fields as prior-attempt data rather than new instructions (invocation).
One thing arrives later rather than at the start: the LOOP.md question. The
agent that ends a loop is not told the question until its work has passed every
check — asking up front would be telling it it is inside a loop
(loop).
Progressive disclosure
Section titled “Progressive disclosure”The prompt announces; it does not inject. A skill enters the prompt as a name
and one line, never as its contents; a subflow as a name and its description;
an input as a filename. The agent reads $SKILLS/<name>/SKILL.md when it
decides the skill applies — loading a skill is a deliberate act, not a default
— and a skill’s scripts are reached only after its SKILL.md has said what
they are for. The same rule everywhere: the agent is told what exists and where
it starts, and it spends context on the rest only when it reaches for it
(skills, subflows).
This is why a stage’s context stays the size of its task. What the format withholds for integrity (what it is not told), progressive disclosure withholds for attention, and the prompt is where both policies are enforced.
Where the workspace sits
Section titled “Where the workspace sits”What $PWD carries enters after the assembly’s purpose. A nonblank ordinary
flow procedure follows workspace context and precedes the stage’s instruction:
the assembly frames, the workspace informs, the procedure orients, the stage
directs.
What a workspace is announced as is one line, whatever the file holds. Under
announce a tree cannot open a section of the prompt, because nothing it wrote
is copied into one — it is named, and the naming is the runtime’s own sentence.
Under use the document’s body is a section, which is what use means and why
the default is ignore: letting a workspace speak is a caller’s deliberate
act, never something a repository can arrange for itself.
What the runtime adds, and does not
Section titled “What the runtime adds, and does not”The runtime normally contributes the names and nothing more. It does not say a file came
from a branch, from the previous stage, or from an alternative that was chosen,
because those words would describe a system the agent is not told about
(invariants). What risk.json means is what the stage’s own
prompt says it means, and the author writing that prompt is the one who knows.
A resume failure artifact is the narrow exception. The runtime labels its named file as prior-attempt data so the failed stage does not treat retained diagnostic text as a new instruction.
Naming the files is still what makes fan-in work. A stage after a three-branch
PARALLEL is told it has research.txt, risk.json, and cost.txt, so
nothing has to go looking and no author has to keep a list of filenames in step
with a folder.
What the agent is not told
Section titled “What the agent is not told”- Where the assembly lives.
- Where the run record lives.
- Where its gate lives, or that a gate exists at all.
- Which provider or model is running it.
- What its timeout is, or how many retries it has left.
- How many repeats of a loop remain.
These are the runtime’s business. An agent that knows its retry budget paces itself against the budget instead of the work. An agent that knows it is on the last repeat of a loop approves the last repeat. An agent that knows where the gate lives can edit the gate, which is exactly what has happened, and half the reason this format exists.
Authentication
Section titled “Authentication”Stability: provisional.
Credential-provider integration and the details of login and import commands may change in a later publication. This chapter still specifies the behavior that ships now.
A runtime calls models, and calling a model needs a credential. The authentication commands manage credentials, and they are the only part of bot that writes them.
The credentials belong to the operator rather than to any workspace, so Pi
owns them in auth.json under its resolved agent directory. The default is
~/.pi/agent/auth.json. No
--home reaches them (the home). A --home given here is refused
rather than ignored, because a person who typed one is expecting per-home
credentials and does not have them. An existing Pi agent directory must be a real directory owned by the effective user with mode 0700. Each operation validates only the Pi files that it uses. An existing auth.json or models.json must be a real regular file owned by the effective user with mode 0600; a symbolic link is refused. Missing paths remain valid. Pi owns later agent-directory and authentication-file creation. Bot does not create a model file.
A credential-free provider catalog validates the agent directory and models.json. A model runtime or authentication listing also validates auth.json. The logout mutation runtime validates the agent directory and auth.json without loading models.json; the full logout command first resolves its provider through the credential-free catalog. Authentication import keeps its separate source and destination checks.
These checks limit which operating-system accounts can supply credential or command-capable local configuration through ordinary file access. They do not sandbox trusted configuration. Pi may run a supported command-backed key from auth.json or a leading !command value from models.json with the operator’s filesystem and network authority. A same-account replacement or path race remains outside this boundary.
What the check does not do is worth saying plainly. It inspects a path and then lets Pi open that path again by name. It does not open the file without following links, and it does not compare the file it inspected with the file Pi then reads, so anything that can replace the path between the two wins. It validates the agent directory and stops: no ancestor of that directory is checked, so an agent directory reached through a writable or symlinked parent passes. The default agent directory sits under the operator’s own home, where only the same account can do either. An agent directory redirected into a place another account can write is outside what these checks establish.
The commands
Section titled “The commands”| Command | Does |
|---|---|
bot auth list |
bounded safe provider authentication status |
bot auth login <provider> |
runs that provider’s own sign-in and stores what it returns |
bot auth logout <provider> |
removes that provider’s stored credential |
bot auth import <file> |
copies one compatible retired map into empty Pi authentication |
The listing
Section titled “The listing”bot auth list [--offset N] [--limit N] [--json|-j] is the current finite
form. It sorts provider identities bytewise before applying offset paging.
The default page holds 50 rows and the maximum holds 200. Human output carries
provider, method, and state, then the shown and total counts. JSON returns one
newline-terminated schema-version-1 bot.auth.list document with rows, page
facts, and summary counts. An empty provider catalog succeeds.
Each row carries only provider, method, state, and credentialType.
Method is login for a provider with OAuth, key for an API-key login, and
ambient-only otherwise. State is stored when Pi’s safe credential metadata
names the provider and unobserved otherwise. Stored public metadata is the
only safe evidence in Pi 0.85.1. unobserved does not mean unavailable.
Credential type is api_key or oauth for a stored entry and null otherwise.
The command never asks Pi for provider authentication status or exposes a
credential value or Pi’s free-text status label. It never resolves ambient
secrets, probes provider files, runs provider commands, refreshes
authentication, or uses the network.
Provider identities are at most 1,024 UTF-8 bytes. Encoded rows are below
4,096 bytes. Escaped human cells are at most 480 bytes, human rows are at most
4,096 bytes, and either complete output is below 1 MiB. Malformed requests
exit 2 before authentication access. Unsafe, corrupt, or invalid runtime
state exits 5 with one bounded error and no partial rows.
Importing the retired store
Section titled “Importing the retired store”bot auth import SOURCE [--json|-j] copies one complete compatible retired
Bot credential map into Pi’s missing or valid empty auth.json. It never
merges, overwrites, selects, resolves, refreshes, or deletes credentials. The
source remains byte-for-byte and metadata unchanged. A successful nonempty
import preserves the source bytes exactly and returns bot.auth.import version
1 with imported: true and the effective providerCount. An empty map leaves
the destination unchanged, returns imported: false and count zero, and exits
1.
The source argument is at most 4,096 bytes. Each credential file is at most
1,048,576 bytes inclusive. The source parent and Pi agent directory must be
real effective-user-owned 0700 directories. The source and any destination
must be real effective-user-owned 0600 regular files with one link. Import
accepts only Pi API-key and OAuth credential shapes, including compatible
provider-specific extra fields.
Import locks Pi’s destination before either file read, then locks the retired source. It revalidates opened descriptors, parent identities, named leaves, and exact source bytes before one atomic rename. The temporary file is exclusive, owner-only, synced, revalidated, and closed before that rename. Failures before rename preserve the destination. Failures after rename retain the exact successful result. Import never prints credential content and emits its own bounded result or stable failure instead of the retired-store warning.
One line per provider the runtime knows, whether or not anything is stored for
it: the provider’s name, how a person signs in to it — its own login where it
has one, otherwise a key — and its standing. The standing is one of two plain
answers: stored, when the credential store’s safe metadata names the
provider, and unobserved otherwise. The command reads no ambient secret, so
unobserved says what was not observed and never that the provider is
unavailable.
Logging in
Section titled “Logging in”The provider owns its own sign-in. The runtime supplies a terminal and nothing else: it prints the URL the provider hands it, prints a device code and where to enter it, asks for what the provider asks for, and reads a secret without showing it. The runtime never opens a browser — it prints the URL and a person opens it, which is what makes a login over a remote shell work.
A login is a conversation, so it needs a terminal: with input piped in, it
refuses up front rather than waiting for an answer that cannot come. The
provider is matched by name exactly, never nearly, because a login stored under
the wrong name is a login nothing will look for. Provider interaction stays on
standard error. Ordinary human success is one bounded sentence. --json or
-j returns one version-1 bot.auth.login result with the canonical provider,
authenticated: true, and only the returned credential type: api_key or
oauth. The result never exposes another credential field.
The provider identity is at most 256 UTF-8 bytes. Each rendered interaction is
at most 2,048 bytes, and a login accepts at most 100 interactions and fewer
than 65,536 interaction bytes. Either success result is shorter than 4,096
bytes. Cancellation before mutation exits 1. A request refusal exits 2. A
provider failure exits 4 without repeating provider failure text. An unproven
storage failure exits 5 without claiming whether storage changed. When Pi
reports that persistence succeeded but in-process synchronization failed, Bot
keeps the truthful success result on standard output, adds one non-retryable
synchronization-failed error on standard error, and exits 5.
Logging out
Section titled “Logging out”bot auth logout PROVIDER [--json|-j] resolves one exact provider through the
credential-free catalog, then invokes Pi’s idempotent logout. It does not list
credentials first. The dedicated Pi runtime uses the live authentication path,
no model file, no creation refresh, and disabled model network. Bot performs no
credential read, authentication or availability check, or provider refresh
before deletion, so stored command-valued credentials are not evaluated. A successful transaction completes even when
the credential was already absent and leaves every other provider alone.
Human output is one bounded completion sentence. JSON returns one
newline-terminated version-1 bot.auth.logout result with only the canonical
provider and result: "completed". Completed does not claim that a credential
existed or that ambient authentication is disabled.
The provider identity is at most 256 UTF-8 bytes and either result is shorter
than 4,096 bytes. Request and provider validation precede the retired-store
warning and credential access. Cancellation before deletion exits 1. Request
refusal exits 2. An unproven storage failure exits 5 without a completion
result. When Pi reports that deletion succeeded but in-process synchronization
failed, Bot preserves the completion result, writes one non-retryable
synchronization-failed error, and exits 5. Pi owns parsing, mutation, typed
post-delete synchronization, and the file lock that serializes same-provider
logout with login and OAuth refresh.
The laws
Section titled “The laws”No command here ever prints a credential, a fragment of one, or an expiry — not a listing, not a success, not a refusal, not a failure. That is the whole of what makes a terminal safe to paste.
A stored credential owns its provider, and the environment answers for the providers that have none. Provider API keys in the environment keep working exactly as their own documentation describes.
The runtime reads no other credential store. Bot’s retired credential file
is $XDG_CONFIG_HOME/bot/credentials.json, or ~/.config/bot/credentials.json
when the variable is unset. That is the path the warning below stats and the
path bot auth import is normally pointed at. If Bot’s retired credential
file exists, bot auth list, bot auth login, and bot auth logout warn once after
validation and before authentication begins. bot run start, bot run resume, and
bot model list never warn about the retired store. Help, capabilities, assembly commands,
checks, and record inspection do not warn. bot auth import is excluded from this
warning and emits its import-specific result or failure. The
retired file remains unchanged and inactive except as this command’s explicit
source. An operator can still roll back to a version that reads the preserved
file.