Tools
A tool is what lets a model do something instead of describing it. In Zuno a tool carries three independent declarations: what side effects it has, whether it may be re-executed, and whether it may overlap with other calls. Keeping those separate is what makes safe parallelism possible without also making retries dangerous.
The default surface
The default model-visible surface is deliberately small:
| Tool | Purpose | Effect |
|---|---|---|
read | Read a file or directory | Read-only |
glob | Find files by pattern | Read-only |
grep | Search file contents | Read-only |
write | Create a file, or intentionally replace one whole | Side-effecting |
report_write | Publish an immutable report in .zuno/reports/, including from read-only investigation | Side-effecting |
apply_patch | Localized, context-verified source edits | Side-effecting |
shell | Run a command under the active sandbox | Side-effecting |
bg | Inspect or cancel background commands, and page output kept out of the transcript | Read-only inspection; cancel is side-effecting |
task | Delegate a bounded objective to another agent | Delegating |
session_message | Send attributed durable peer context to another root or this root's child | Side-effecting; root sessions only |
job | Inspect background job state | Read-only |
webfetch | Retrieve one URL | Read-only |
web_search | Batch web search | Read-only |
skill | Discover and load reusable instructions | Read-only |
question | Ask a structured clarification; wait in Plan, return immediately in Work/Goal | User-mediated |
question_async | Publish optional questions and continue independent work | User-mediated |
plan_exit | Request approval of the exact current Plan and saved Work identity | User-mediated; Plan only |
Durable work state adds plan_get, plan_update, todo_get, todo_update, and goal_get/goal_update. Bounded memory_read and memory_update appear when memory is enabled; updates apply automatically unless a review policy is explicitly configured, and council_run when the active agent can reach it.
task_context preserves one meaningful long task's objective, interpretation of authorized actions, prohibitions, user-source IDs/digests, decision owner and delivery/safety checks. get reads; update patches the current revision; replace explicitly starts a new task. New contexts require objective, task_kind: delivery|inspection and source_message_ids. The generic tool intent field is only a UI label, not the task kind. Omitted update fields retain their values, and prohibitions/checks merge: "do not announce" does not erase the previously authorized repair. Passed safety checks cannot complete a delivery task without passed delivery checks and evidence references. Check definitions stay fixed during ordinary updates. Explicit revise_checks: true requires user sources and resets the changed check to pending with no old evidence. Source digests cover all stored user-input parts, including attachment references; runtime-generated reports and compaction markers cannot be authorization sources.
This is bounded application-owned context (ManagedContext), not a permission grant, Goal resume or scheduler wake. Explicit permission rules still apply; child agents return decisions to their parent instead of rewriting root context. Source changes are reported as stale/historical; current user instructions win. Skip this tool for self-contained questions and one-step work.
Task, workflow, and Council outcomes
task requires top-level agent, objective, deliverable, instructions, and success_evidence, including when resuming with task_id. intent only labels the call in the UI and cannot replace objective. Follow the JSON example in the tool description; do not nest the fields under contract. Missing-field feedback uses the current schema to explain how to correct the call without automatically filling fields or starting a child.
When waiting is the only useful next action, keep the command or child in the foreground. A bg handle or backgroundPurpose: "remoteObserver" does not mean the operation was detached: continue the same foreground handle and preserve steering/interruption. Use background mode only for an explicit parallel split with identified independent local work for the parent. Do that work after dispatch; never background the sole task and finalize while it runs.
Use task for one bounded delegation, workflow for a configured dependency graph, and council_run for independent reports followed by synthesis. Inspect the durable job and child-session evidence when one fails: successful model requests and completed tools can precede a timeout. Provider request/retry time and total task elapsed time are different measurements. A failed task is not automatically replayed. When available, its <execution_progress> block preserves native progress separately from <task_result>; progress alone does not attest to completion.
The built-in balanced-review Council reserves 60 seconds of its 600-second total for synthesis, leaving one shared 540-second seat phase including queue waits and retries. Each attempt receives its remaining hard time and UTC deadline in the durable prompt. 0/2 counts valid terminal reports against quorum, not provider successes. Cancellation, failure, timeout, or uncertainty retain already recorded progress without turning it into a valid report; Council success still requires quorum and successful synthesis. Only a natively completed seat with an invalid structured report may use a bounded preset retry; a native failure or host Err never replays the seat. See Agent orchestration for scope, configuration, and recovery.
Durable questions and waiting
Questions have stable request/item IDs and a revision. TUI, HTTP, and ACP use the same QuestionPort: publish, list/get, respond, and wait are separate operations. In Plan, question waits for the first response; choosing Defer releases that tool wait without answering the question. In ordinary Work and active Goal execution, the same question tool publishes an optional clarification and returns a receipt immediately. It does not create a session wait or pause the Goal. question_async also returns immediately, including in Plan. Pending optional questions do not prevent independent work or a final summary; the agent continues within existing authorization using available evidence and stated assumptions. Partial answers preserve completed items and leave the others pending.
Actual answers enter the durable inbox exactly once, not both the tool result and a later callback. Responses carry commandId and expectedRevision; repeating the same command is idempotent, while stale revisions and changed command bodies are rejected. question_async is governed by the question permission key. No answer, highlighted option, saved draft, timeout, cancellation, skip, or Defer action is consent. A skipped question does not require another clarification loop.
Ordinary deferred clarifications receive a native 120-second auto-defer deadline: 60 seconds of grace and a final 60-second countdown. Timeout defers the ordinary form without answering or closing it, choosing the highlighted option, or creating a model input. The stable question remains available for a later answer. User interaction sends Snooze and preserves drafts. The deadline survives restart; duplicate timers and timeout/answer races use the same transaction/revision rules. Blocking questions, required input, Plan approval and Goal resume do not auto-defer. Model-authored child clarifications are returned to the parent; native permission and required-input routing retain their existing controls.
Required input is an explicit control, separate from optional clarification. Goal-owned goal_request_input binds the observed Goal revision and atomically records both the Goal pause and session wait. Only a real answer to that exact request can satisfy it; skipping, deferring, or unrelated callbacks cannot. Delegated children report blockers to their parent and cannot contact the user through these tools.
plan_exit publishes a deferred, closed approval request. Only an explicit approve action can authorize Work, and only for the displayed Plan revision, saved Agent/model identity, and review state. Early approval waits for the source Plan turn's successful handoff. Changes or interruption invalidate stale consent. Draft review risk acceptance is explicit; the tool cannot provide it for the user.
Automatic continuation requires runnable work. The current cycle's adopted, authorized in_progress Plan step is sufficient without duplicate Todos, but does not bypass linked Todo dependencies/owners, unsettled Jobs or recorded gates. An unowned Plan or blocked Todo alone is insufficient. /resume explicitly resumes previously authorized Work after a pause; it cannot bypass a pending human/external wait or authorize a Plan. A Goal must first be resumed with /goal resume when it is paused.
Terminal bg output, bg wait, and callback delivery share a completion receipt. An inline read supersedes an unconsumed queued callback atomically; already promoted/consumed callbacks remain protected. Completions retain their original work cycle, rather than creating a new cycle that resets no-progress detection.
session_message is filtered out of every delegated child tool snapshot, even when the parent Attempt contained its schema. At execution it verifies again that the source is a root, the target is active and in the same project, and a child target descends from that source root. The durable target input explicitly identifies the source session, Agent, and title and says that peer context is not user authorization. Permission rules still govern each send.
When connected MCP tools survive capability and permission filtering, Zuno keeps their implementations executable but normally withholds their full JSON schemas. A conditional tool_search tool searches compact metadata; each match expands the provider-visible tool set on the next model step. This avoids paying the prompt cost of every connected service on every request. See MCP servers.
Optional continuity tools appear only when enabled by continuity and retained by the final tool and permission filters:
| Tool | Actions | Scope |
|---|---|---|
history | list_windows, list_items, read_item, search_contents | Normalized evidence from the current session |
notes | list_files_by_prefix, read_file, search_contents, append_to_file, write_file | Logical documents for the current session and Agent |
History windows are delimited only by successful compactions. Returned content excludes reasoning, encrypted values, synthetic internal prompt text, and binary attachment bytes, and must be treated as data rather than instructions.
Notes never expose a host path. A scope may contain at most 100 documents, 256 KiB per document, and 1 MiB in total. Both write actions require the exact expected_revision; use 0 only to create a document. The trusted tool call_id, request digest, and revision make a repeated delivery idempotent while rejecting stale concurrent writes.
Explicit Plan collaboration mode requires durable strategic Plan state. In ordinary Work mode, the model decides whether a Plan adds value from the full conversation; the host does not parse prompt keywords or generate visible generic steps. Immediately before every plan_update, the model calls plan_get and copies its returned revision into expected_revision. Only action=create after plan_get returned null may omit it; replacing an existing root, including a genuinely new objective, still carries the current revision. The model uses plan_update action=create for the first Plan or a genuinely new objective; patch changes only named step ids, append adds host-identified steps, push opens a focused child, and pop restores the exact parent without retransmitting the Plan. Every existing-Plan mutation requires the current expected_revision. completed and superseded are terminal. On plan_update, notes, and history, action is a required enum: the wire schema lists every operation and the fields each one needs, so a call that names no operation is rejected before it reaches the tool.
plan_get and todo_get attach a semantic progress fingerprint to their result. Cross-cutting arguments such as intent are excluded. Three unchanged, consecutive, single-read steps stop with stagnant_tool_loop instead of spending provider requests indefinitely. A mutation changes the fingerprint and resets the count.
plan_update and todo_update also request dynamic-context invalidation. If another provider request will run, the host must rebuild Goal/Plan/Todo/Job projection from the committed database first. In explicit Plan mode, a successful Plan mutation changes the one-time Required instruction to Maintain. A missing or failed refresher pauses the turn rather than knowingly sending a stale developer-priority snapshot.
After a provider final, the durable reconciliation driver respects exact waits and protected gates. An ordinary final ends its cycle even if Plan/Todo work is unfinished; it never marks that work complete or buys another model turn merely to reconcile it. An active owned Goal retains continuation and a persisted progress fingerprint; three consecutive identical observations pause with typed no_progress. Tool-result followups and explicit foreground waits remain inside the logical turn.
The built-in read-only plan Agent has one typed handoff exception: a completed planning answer leaves the current Plan and Todos, with their existing statuses, as execution work for a later Start Work turn and does not spend reconciliation continuations. Retained Jobs remain observable and do not authorize Work by themselves.
See History and Notes continuity for complete enable/disable, profile-overlay, permission, revision, and restart guidance.
edit, execute, and lsp exist as registered slots but are not part of the default surface. edit remains available to explicitly constructed profiles; the default editing path is apply_patch plus write.
Report files from read-only work
report_write accepts name (a plain filename such as audit.md) and content (complete UTF-8 text, at most 1 MiB). The host selects the path under the worktree's .zuno/reports/, includes a digest of the session and call identity in the filename, and returns the exact path, byte count, and content SHA-256. A new call publishes a new artifact; it cannot overwrite an existing report or select an arbitrary destination. Paths, symlinked ancestors, and filenames unsafe on supported platforms are refused.
Report authority is independent of edit and does not make Shell write-capable. The final parent/child tool intersection and the report_write permission still apply. Strict mode treats publication as a side effect; replay policy remains Never. The complete call and its reportArtifact receipt are durable. Native child task reports also include receipts in artifacts, so background delivery and replay retain the same file references. Cite the returned path; a parent with edit authority can integrate it elsewhere. Reports are excluded from Git and are retained as deliverables, including after session pruning, until explicitly removed.
apply_patch conflict recovery
apply_patch uses a stable SHA-256 read receipt plus a read generation instead of trusting remembered prose. An existing source used by update, delete, or move must first be read; an already-existing move destination must also be read. The tool acquires one mutation lock, validates every affected file, and only then writes, so any preflight conflict leaves all files unchanged.
Mutation conflicts are typed and model-correctable, but never automatically replayed:
| Conflict | Meaning | Required recovery |
|---|---|---|
ReadRequired | The current file has no valid read receipt | Read the named resource before constructing a mutation |
StaleRead | The file changed after the recorded read | Read the current file and rebuild the operation from that content |
ContextMismatch | A hunk no longer matches the current logical lines | Read the named hunk area and generate a smaller patch with fresh, unique context |
IdenticalReplay | The same operation digest was submitted against the same file content | Do not resend it; revise the operation, or wait for a real file change and read again |
The conflict includes the resource, operation digest, current content digest, hunk number/title when applicable, and a concrete requiredAction. Matching is performed on logical lines while preserving the original BOM, LF or CRLF style, and final-newline state. Patch grammar errors remain InvalidArgs. If a later I/O or formatter failure occurs after a write, the result is Uncertain, names the paths observed as changed, and requires inspection rather than mechanical replay.
webfetch accepts only credential-free HTTP(S) targets. Zuno resolves and validates every address, rejects a whole mixed public/private DNS answer, pins the validated addresses, and repeats validation for each of at most five redirects. It honors the process HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY policy while sending the validated target IP through HTTP, HTTPS, SOCKS4, or SOCKS5 proxy routes and preserving the original Host/TLS SNI. A configured proxy failure never falls back to direct. The default timeout is 30 seconds and the per-call maximum is 120 seconds; timeout errors report route, phase, and elapsed duration.
web_search keeps provider credentials and full wire URLs out of errors and logs. Diagnostics identify only provider, scheme, host, path, status, and error category; they do not contain an API key, authorization header, or full query.
glob and grep drive the official rg executable, with Zuno contributing only typed arguments, cancellation, bounded decoding, stable ordering, and path validation. Ripgrep 14 or newer must be available for those two tools. Discovery is lazy and scoped to them: a missing rg makes glob and grep report a typed tool error, and never a silent fallback to a slower walker, but it does not block Zuno from starting, reading configuration, reaching a provider, or opening its database.
Both tools report a refused call instead of an empty result. An unparseable pattern or include glob is invalid arguments naming the pattern rg itself quoted, so a refusal caused by the include is never blamed on the regex; a regex over ripgrep's own compiled-size limit is reported the same way. A run whose output exceeds 64 MiB is refused with advice to narrow the pattern, path, or include, because a stable order cannot be decided from part of a parallel walk. A grep restricted to one path that rg never managed to read is a refused call, not "no matches".
A path is an identifier, not display text, so both tools return only paths they can name: every result comes back under the name the file actually has. A file whose name is not valid UTF-8 — a legacy encoding from an old archive, or a lone surrogate in an NTFS name — is left out of the results rather than reported with a replacement character that would name no file, and the same holds for the file a grep match lives in. On Linux and macOS a filename containing a backslash comes back verbatim, because a backslash is an ordinary filename byte there; on Windows, ripgrep's backslash separators are reported as /, which that platform accepts. A file name containing a newline is returned as the single path it is.
Matching lines are capped at 2000 UTF-16 code units and a single rg record over 1 MiB is skipped. The rendered glob/grep output marks the result as truncated whenever a match was dropped, whether by the limit or because its path could not be named, and a path is returned exactly once, as the identifier of one real file.
A directory the walk cannot enter contributes no results and is reported as "No files found", not as a permission failure: ripgrep's per-path diagnostics are suppressed so that one unreadable directory elsewhere under the root cannot turn the common "my pattern matched nothing" outcome into a tool failure. Zuno does not distinguish "not there" from "could not be read" for a subtree.
For a Shell command that only observes remote work, set backgroundPurpose: "remoteObserver". This value is persisted with the background execution and projected through bg and the durable completion input. It does not change permissions or make the remote system part of the local process result; it requires the resumed Agent to refresh authoritative remote state before claiming that a CI run, deployment, or release completed.
While a remote observer remains running and durable Plan, Todo, or Job work is unfinished, the reconciliation driver records waiting_background. The current turn may end without polling or asking the user how to repair Plan state. The observer's durable terminal report wakes the session and normal reconciliation continues after the authoritative remote refresh.
Completion is callback-first. Use bg wait only when the current step synchronously depends on the result; each call is capped at 60 seconds. A synchronous wait and the callback compete for one durable completion owner, so only one path can start a continuation turn.
Reading output in windows
Output that is not in the transcript is read back through bg, one bounded window at a time. There are two kinds, and one cursor convention:
| Action | Reads | Names the source with |
|---|---|---|
bg output, bg wait | A background execution's output | taskID |
bg artifact | Output a size limit withheld | outputPath |
cursor is an absolute byte offset and limit is the size of the window. Both reads return the cursor the next window starts at, plus hasMore, so you page forward by handing that cursor back rather than by guessing an offset. Both also return windowFrom and hasEarlier, the same two facts pointing backwards, so a window states where it begins instead of leaving you to subtract its length from its cursor. limit defaults to 16384 bytes and is clamped to 51200. That ceiling is fixed — one window of bg is bounded the way one read of a file is — and it is deliberately not a function of tool_output.max_bytes: lowering the inline threshold does not shrink a retrieval window, because a retrieval is how output that threshold withheld is read back. A window is bounded before anything is read, and asking for more than the ceiling is clamped rather than refused.
A running command retains its most recent 2 MiB in memory while its complete output stays on disk. A cursor older than what is still retained is served from that file, so the beginning of a long-running command's output stays reachable through bg instead of requiring a shell command to slice the file by hand. fromDisk reports which copy answered.
bg output and bg wait without a cursor return the newest window: the tail a terminal renders, ending at totalWritten, because that is where a command says what happened. Polling a running command that way keeps showing new bytes, and windowFrom is where to page backwards from. bg artifact is the other way round — a withheld artifact is not growing, and the notice that withheld it already stated its total size — so a read with no cursor starts at byte 0 and pages forward.
Retrieval never re-runs the command that produced the output, and never goes through the output limits: a retrieved window is already bounded, and passing it through the limits would withhold it a second time. That holds inside execute, where the sub-calls share one 50000-byte budget: a window larger than this composition's share is neither inlined nor persisted again. The block names the artifact that still holds every byte and the limit that would fit, so the next read is smaller rather than aimed at a new file.
What a shell exit status proves
A pipeline's exit code is the last stage's exit code. cargo test | tail -5 exits zero when the tests fail, because only tail decided it. Zuno therefore puts set -o pipefail in effect for every shell call by default, so a failure at any stage is the command's failure.
exitPolicy selects that behaviour explicitly:
| Value | Effect | Exit status authority |
|---|---|---|
pipefail (default) | A failing stage of a pipeline fails the command | Authoritative on bash, ksh, and zsh |
all | Also stops at the first failing command in a sequence | Authoritative |
last | Only the final stage decides, the POSIX default | Derived |
Choose last deliberately, for a command meant to tolerate a failing stage.
Every result carries a verification receipt saying what ran, what it decided, the exit code, and how far that code reaches. The distinction is the point: a derived status is recorded but is not evidence, because the code came from a stage that never saw the failure. The interpreter matters too. set -o pipefail is not in POSIX, and dash exits without running the command at all when it is asked for it, so an interpreter outside the known-capable set reports derived and names itself in the receipt. sh is treated as unknown on purpose, since the name does not say what is behind it. PowerShell has no equivalent, so only all is authoritative there.
The configuration is only the starting point. Zuno also reads the command's own text, because text that runs inside an authoritative shell can take the guarantee apart: cargo test || true exits zero whatever the tests did, set +e turns the prologue's option back off, bash -c '…' runs under its own options, a check written as an if or while condition exits zero when that check fails, a loop reports only its last iteration, and under all a && chain before the last statement is exempt from set -e. In PowerShell, one native command piped into another leaves $LASTEXITCODE holding the second one's code alone.
None of this changes what runs. The command executes exactly as written, and a || fallback is meant to win. What changes is the claim: it drops to derived with a limitation naming the construct and the remedy. The reading is a syntax tree rather than a search for characters, so echo 'run tests || true' masks nothing and keeps the configuration's verdict.
A stored receipt is addressed by an id that appears in the tool result as [verification rcp_…]. That id, not a recollection of the transcript, is what satisfies a Goal completion criterion that requires evidence.
Exit codes the guard reserves
Every shell command runs behind a child-process guard, the small supervisor that ties the command's process tree to Zuno's own lifetime. The guard reports through the same exit status as the command, so three codes may be the guard's rather than the command's, following the convention timeout, env, and nohup share: 125 means the guard itself failed, 126 means the program exists but could not be executed, and 127 means it could not be found.
The three are read differently because they mean different things. A 125 says nothing about whether the command ran or what it changed, so the result is an uncertain outcome rather than a Failed exit 125 receipt: it is never replayed automatically, and it asks for a look at the real state the command would have changed before anything else is decided. A 126 or 127 says the program never started, so the code is recorded but carries no authority over the command; nothing ran that could have decided anything.
A command that chooses to exit 125 of its own accord keeps its ordinary receipt. A reserved code is read as the guard's only when the guard's own diagnostic is in the captured output, and only the guard writes that line, so a make that reports Error 125 is still make's verdict. A command killed by a signal has no exit code at all and is reported as killed by a signal, which likewise decides nothing about the work.
Cancelling a running command
Interrupting a turn cancels the shell command it was waiting on, and what comes back is a settled result rather than a bare error. Everything the command had written is preserved, the exit information is reported when there is any, and the result states which of the two cancellations happened — because the two do not permit the same next step.
| What the interrupt found | Exit status | Verification receipt |
|---|---|---|
| The command had exited and reported its own status | Reported, with its usual authority | The receipt a completed run earns |
| Anything else | Reported when the record holds one, but it decides nothing | unknown, with no exit code |
The first case is not uncertain. The kill landed on a process that was already gone, so the command's own verdict stands and the result is the one it had earned; it is marked as cancelled and nothing else changes.
The second case is uncertain, and says so in its first line: the command decided nothing, and what it had already changed cannot be read off this result. It asks for the authoritative state the command would have changed to be inspected before anything is retried. shell never replays a call on its own — a command can commit half a write before the kill reaches it — so this outcome ends with a look at real state rather than with a second attempt.
A number in the exit status does not by itself move a cancellation into the first case, and the result names what the number fails to prove instead of pretending there is none:
- an
exit 125that is the guard's own failure says nothing about whether the command ran, for the reason given in the reserved codes above; - a command that reached its hard ceiling was killed there rather than deciding anything;
- a status the run reported but Zuno could not settle — an output capture that broke after the process exited, say — is not a verdict the result will let anything cite.
Preserved output goes through the ordinary output limits. A cancelled command that produced more than the inline threshold has every byte saved and is handed the notice naming the windowed read, with the cancellation statement still in front of it. Nothing is truncated, and a cancelled command that wrote nothing says (no output) rather than coming back blank.
What a shell command inherits
A shell call runs with the host environment the Zuno process itself has, minus Zuno's own. ZUNO_* is Zuno's private configuration namespace, and the whole namespace is withheld from a model-composed command:
| Variable | Treatment |
|---|---|
ZUNO_* | Withheld in full. Not one name in the namespace is inherited, including the ones that look like bare identity |
ZUNO, AGENT | Inherited. The bare markers saying a process was launched by Zuno are outside the namespace |
| Everything else | Inherited |
This is a namespace rule rather than a list of the variables known to hold a secret, because a list has to stay right for every variable Zuno ever reads and was wrong once already. ZUNO_AUTH_CONTENT was withheld while ZUNO_CONFIG_CONTENT was not — the same injected-document shape, carrying the same provider keys under provider.<id>.options.apiKey, but it arrived as configuration rather than as credentials. Withholding the namespace has to be right once, and the next variable Zuno reads is withheld before anyone has to notice what it holds.
There is no allowlist. An earlier release inherited three names — ZUNO_PID, ZUNO_CLIENT, and ZUNO_WORKSPACE_ID — on the reasoning that a pid, a client surface, and a workspace identifier are none of them a document or a credential. That is true of each value and beside the point: the pid is the address of every document withheld above. On an unconfined path tr '\0' '\n' < /proc/$ZUNO_PID/environ on Linux, or ps eww $ZUNO_PID on macOS, reads the Zuno process environment straight back, so handing the pid to a model-composed command turned a discovery step into a one-liner. The other two went with it because nothing outside Zuno reads them: ZUNO_CLIENT is read by Zuno itself in process, and ZUNO_WORKSPACE_ID by nothing beyond the CLI's own flag snapshot. A script that only needs to know it is running under Zuno reads ZUNO or AGENT, which are outside the namespace and untouched.
Names are compared case-insensitively, because Windows environment variable names are, so %zuno_server_password% names the same secret there. The fold is only ever allowed to grow the withheld set: it decides what is removed, and there is no allowlist for it to collapse a name onto. That direction matters — with an allowlist, a Linux ZUNO_Pid is a different variable from ZUNO_PID and could hold anything, and a case-insensitive comparison would have inherited it for looking like an allowlisted name.
Removal happens before any host-supplied environment hook, so the host stays the single place that decides what a model-composed command may read. A deployment that genuinely needs a value in the tool environment puts it back through that hook, deliberately; nothing in the shipped configuration puts one back.
Everything outside the namespace is inherited on purpose. A wildcard filter over *_API_KEY and *_TOKEN was considered and rejected: it silently breaks gh, aws, az, and gcloud, along with every user who exports a token because a command needs it. A tool that quietly removes the credential a command requires fails worse than one that keeps it, because the removal surfaces later as an unexplained authentication error somewhere else.
One consequence is worth stating plainly. A nested zuno launched from inside a shell call inherits none of that namespace — not ZUNO_CONFIG_CONTENT, ZUNO_CONFIG, ZUNO_CONFIG_DIR, ZUNO_DB, the ZUNO_MODELS_* variables, or the ZUNO_SANDBOX_* variables — so it resolves configuration, credentials, and its database the ordinary way, and needs its own. The interactive terminal is unaffected, because its shell is driven by you rather than composed by a model.
Withholding is defence in depth, not a containment boundary. On an unconfined path the Zuno process environment is still readable from the host, so a variable being absent from the command's own environment raises the cost of reading it rather than guaranteeing it cannot be read. Sandboxing is what narrows that; this removal is what stops one env or one printenv from being enough.
The pre-flight repository read budget
A git commit or git add is inspected before it runs, so the call can refuse to deliver generated state. That inspection makes up to four git reads, and all of them share one thirty-second budget for the whole phase — not thirty seconds each. The number is a total: the reads race a deadline that is the budget minus a small reserve, and the reserve is what stops a read that never answered, so a configured thirty seconds cannot admit a sixty-second phase.
A read that does not answer in time is a refusal, not a pass:
`git status --porcelain` did not answer within the 30s this command's pre-flight repository reads share, so the repository state it depends on is unknown; it was not runThe command is not run. An unknown repository is not an empty one, and reading a hung read as "nothing to report" would turn a stalled .git into permission to commit. Cancelling the call refuses it the same way. In both cases the abandoned git and anything it started are stopped as a group before the refusal returns.
What a shell call reports it wrote
write, edit, and apply_patch each name the files they changed. shell now does too, on the same writtenPaths metadata, so a command that edits the workspace is visible to everything built on that key — the goal store's escalation from a question to a change, the freshness mark that retires a stale verification receipt, the changed-file list an interrupted call settles with, and every client's tool-call view. Before this, shell {"command": "sed -i 's/foo/bar/' src/lib.rs"} could complete a goal with no evidence at all, because nothing had told the store that anything changed.
The report is observed, never inferred. Each statically resolvable path the command names is examined before and after the run, and a path is reported only when the two observations disagree — because the filesystem says the file changed, not because a command name looked like a writer. That makes it a deliberate lower bound. Not reported: a target the shell expands ($OUT, *.rs, $(ls), a here-document, a redirection), which is skipped rather than guessed; a path outside the workspace and outside the directories the call was granted; a deletion, because the key means "this file is now here to be re-read"; a rewrite that leaves both size and modification time unchanged; and a command promoted to the background, which is still writing when the call returns. A missing path costs evidence. A fabricated one would retire a receipt over a file nothing touched, so the bound errs toward silence.
Effect classification
Every invocation classifies as one of four effects, and the default is the strict one:
| Effect | Meaning | Strict-mode approval |
|---|---|---|
ReadOnly | Observes state without changing it | Not required |
UserMediated | Requires human input by design | Not an approval surface |
Delegating | Runs child work that carries its own effects | Each child call re-evaluated |
SideEffecting | Default. Changes state or reaches outside | Fresh approval required |
Because SideEffecting is the default, an unknown harness or MCP tool fails closed. A mixed tool may classify from validated arguments: bg list, bg output, bg wait, and bg artifact are read-only while bg cancel is side-effecting.
Native reads, glob, grep, skill and session and job inspection, tool_search, read-only LSP, MCP resource reads, webfetch, and web_search do not receive the extra strict prompt. Shell, file writes, durable state changes, delegation, product agents, extension lifecycle mutations, and unknown MCP tools do.
Mixed tools are resolved from their validated action. Every history action and the three Notes read actions are ReadOnly; Notes append and replacement are SideEffecting. An absent or unknown Notes action fails closed as SideEffecting.
Replay policy
Tool execution is at-most-once by default. Never is inherited unless an implementation explicitly declares Safe, and the current safe set is read-only or idempotent inspection: file reads, glob, grep, skill lookup, current-session history, Notes reads, job status, LSP inspection, goal status, connected-tool metadata search, and web search or fetch. Notes writes remain Never.
| Policy | Behaviour after a failure |
|---|---|
Never | Persist the result, hand it to the model next step, require authoritative-state inspection before any new mutation |
Safe | May be attempted again after backoff |
The loop never mechanically replays a call. A timeout or lost response around a side effect is an uncertain outcome, including the case where the external effect may have completed before the response was lost. A later recovery turn receives a notice naming the retry attempt, derived from SQL rather than from prose in the transcript.
This is the reason a shell command that timed out is not simply run again.
Concurrency
Overlap is a separate declaration from replay safety.
| Policy | Behaviour |
|---|---|
Exclusive | Default. A two-sided barrier: earlier overlapping calls settle first, and no later call starts until it settles |
ParallelSafe | May overlap with other non-exclusive calls, under the configured bound |
IsolatedBackground | May overlap while running outside the foreground path |
The dispatcher resolves tools, validates arguments, runs hooks, and asks permissions in the model's order. It then executes consecutive non-exclusive calls under the bound and persists results in the original call order regardless of physical completion order. Shell, writes, unknown extension tools, and MCP tools without an explicit declaration remain exclusive.
{
"concurrency": {
"tool_calls": 8,
"delegations": 8,
"mcp_connections": 8,
"lsp_requests": 4
}
}2
3
4
5
6
7
8
Every field accepts 1..=64. Setting one to 1 restores serial behaviour for that layer.
Enabling and disabling tools
The top-level tools map is a per-tool switch keyed by tool name:
{
"tools": {
"webfetch": false
}
}2
3
4
5
That is availability, not authorization. Authorization is permission.rules, and an agent may also declare an exact tools allowlist that replaces rather than extends the default surface. See Permissions and sandboxing and Custom agents.
Refusal is an outcome, not a failure
Malformed arguments, an unavailable tool, and a permission denial all emit a dispatch- blocked event with invalid_arguments, unavailable, or denied before the model-visible error result is appended. Durable state keeps outcome: "blocked" and the block kind, so a client can say the requested effect never ran rather than implying it failed part-way.
Process, transport, and implementation failures remain error outcomes. The distinction is worth knowing when reading a transcript: blocked means nothing happened.
Output limits
{
"tool_output": {
"max_bytes": 51200,
"max_lines": 2000
}
}2
3
4
5
6
Those are the defaults. Output beyond either limit is withheld rather than allowed to consume the model window. Nothing is truncated and nothing is lost, and the call still succeeds: every byte is saved first — for shell, exactly the bytes the command wrote rather than a decoded copy of them — and what the model receives in place of the output is a notice naming the measured size, the limit it crossed, the artifact and its size, and the read that gets it back, which is bg with action: "artifact".
Withholding is an outcome, not a failure. The exit code, the verification receipt, and the sandbox facts of the command are all still on the result, and the artifact is recorded on the durable tool part twice over: in outputPaths, and in a typed withheldOutput object, so a client surface or a later turn can offer retrieval without parsing the notice. Sending the output back inline is still possible with accept_large_output: true, but that re-runs the call and spends the context this limit protects, so it is a deliberate back door rather than the first thing the notice suggests.