Skip to content

Running

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).

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.

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.

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.

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.

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.

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.

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.

  1. Bind the stage’s slots: a fresh $INPUT holding one named file per source, an empty $TMP, a path for $OUTPUT, the flattened $SKILLS, and an empty $SUBFLOWS when any subflow is in scope.
  2. Run before, if present. What it writes is the agent loop’s input.
  3. 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.
  4. Check that $OUTPUT exists, 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 exit 75 is an external blocker and ends the stage.
  5. 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.
  6. On a refusal or reported fault, skip step 4 entirely and fail the stage. A refusal exits 1 with cause refused; a reported fault exits 2 with cause fault. Neither spends a retry.
  7. 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 offer clean-temp or finishing anyway. The warning names $OUTPUT for output-accounted evidence and $PWD for 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 $TMP empty 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.
  8. Run success or failure, according to the outcome.
  9. Seal the output and move on. When a stage settles, destroy its $TMP; a flow-shared $TMP remains until that flow settles. This cleanup applies to every settled outcome, including a refusal, fault, or handled signal. A SIGKILL cannot settle and leaves its temporary directory behind. A teardown that fails never displaces the settled outcome: the failure is recorded as its own tmp_teardown event 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.

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, done or skipped, required evidence, and for skipped a 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, continue or stop, 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 $TMP directory 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.

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).

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.

Hooks and gates are ordinary processes.

  • A child gets the stage’s timeout as 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/success hook that hangs exits 2: 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, then SIGKILL after 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/success hook whose output never closed exits 2 with cause fault, 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 SIGKILL can leave that evidence for a later busy reader; a malformed or in-progress entry is conservatively busy. A descendant that escapes its process group with setsid or a double fork is outside ordinary hook and gate containment.
  • A gate or before/success hook’s exit code is its verdict, except for 126 and 127; only a gate’s evidenced 75 is 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/success hook exits 2, 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 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).

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).

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.

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).

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

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.

Stability: stable.

Invocation is how a run starts: how the assembly is named, where the request comes from, and how options resolve.

Terminal window
bot run start review/change "the auth change in #482 looks wrong"
bot run start review/change @tickets/048.md
bot run start review/change < payload.json

A 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.

Terminal window
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.

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.

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.

An assembly that declares slots (slots) is supplied them at invocation, each as a long option named after the slot:

Terminal window
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.

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.

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.

Terminal window
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. $PWD is a path; nothing about its contents enters the prompt.
  • announce — the prompt names what $PWD carries, 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 $SKILLS as 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.

  • 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.

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:

Terminal window
bot run start review/change "…" # $BOT_HOME/assemblies/review, flow change
bot run start team/review/change "…" # $BOT_HOME/assemblies/team/review
bot run start ./review-bot/change "…" # an explicit path, same flow rule

There 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).

Terminal window
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).

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.

  • Its instruction — the markdown body of its stage, including the ## Checklist heading if there is one (the checklist).
  • The assembly’s purpose — the body of ASSEMBLY.md, which enters every stage (the assembly).
  • Its input — that $INPUT is 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 $SUBFLOWS when 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 $PWD carries — nothing at all by default; its document and its skills by name under announce, and its document’s body under use (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 static Step 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).

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.

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.

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.

  • 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.

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.

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

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.

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.

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.

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.

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.