Skip to content
Developer Preview

Approvals

An approval is a record flow, not a dialog. When the runtime needs a human decision before executing a tool action, it appends durable approval records to the session log and parks the requesting work; the wire model is a fold of that flow. The durable identity is the approvalId; an approval is pending while its opening record has no terminal decision record, and the pending set is a log read that host restarts recompute identically.

Because it is a record flow, everything a dialog needs falls out for free: idle unload is blocked while an approval is pending, resume returns pending approvals as pointers and re-issues the requests, and the first durable decision wins with every other submitter informed by a typed conflict, never raced silently.

The subject says what is being approved. It is an open discriminated union of five kinds — shell, file access, network, process, and generic tool — and clients must render unknown kinds generically and must never auto-approve them: an auto-approval rule keyed on subject kind must fail closed on any kind it does not recognize. Shell subjects are multi-stage: the runtime parses the command into independently satisfiable stages, and the approval’s current requirement id names the stage a decision must target next.

The cycle has three legs:

  1. Request. The server sends approval/request — a server-initiated JSON-RPC request with a connection-scoped id — to the subscribed connection with the subject, the correlation ids, and a server-minted choice menu. Your response to that request is a presentation receipt only — it changes no state, and there is no dismiss-without-deciding on the wire. The same payload also folds onto the view stream as the approval/requested view event, which is what view/page returns for catch-up; the frame you answer is always the approval/request request, never the view event.
  2. Decide. The decision travels as an ordinary idempotent command, approval/decide, never as the request’s response — a response has no retry handle, while a command gets value-identical replay across reconnects and host crashes. The decide names the approval, the current requirement (the multi-stage race guard), and a choiceId from the server’s menu; clients select choices, never construct them.
  3. Resolved. Once the terminal decision record is durable, the approval/resolved view event broadcasts — including to the connection that decided, whose ack was only admission. It is protected-set delivery: never gapped, never dropped, never opted out, so a client that saw the request can always dismiss its dialog.

Here is the decide command and its ack from the recorded round-trip scenario (the requirement id targets stage zero, and the ack’s terminal flag says this decision produced the terminal record):

{"dir":"client","raw":"{\"jsonrpc\":\"2.0\",\"id\":\"b9\",\"method\":\"approval/decide\",\"params\":{\"sessionId\":\"0198f0aa-1111-7000-8000-0000000000aa\",\"commandId\":\"018f6a2a-3333-7abc-8def-00000000d001\",\"approvalId\":\"0198f0ac-7777-7000-8000-0000000000e1\",\"requirementId\":{\"approvalId\":\"0198f0ac-7777-7000-8000-0000000000e1\",\"sourceIndex\":0},\"choiceId\":\"allow_session\"}}"}
{"dir":"server","raw":"{\"jsonrpc\":\"2.0\",\"id\":\"b9\",\"result\":{\"commandId\":\"018f6a2a-3333-7abc-8def-00000000d001\",\"status\":\"accepted\",\"approvalId\":\"0198f0ac-7777-7000-8000-0000000000e1\",\"terminal\":true}}"}

A decide that loses a race gets the typed approvalAlreadyResolved conflict carrying the winning resolution, so the losing dialog settles without a page read. A stale requirement id gets its own typed approvalRequirementStale error carrying the current requirement, and a choiceId that is not on the server’s menu gets approvalChoiceInvalid.

Modes. session/setApprovalMode switches the session’s enforcement mode among four preconfigured mode names — the wire spellings are allowAll, promptUnmatched, onRequest, and denyUnmatched (allow everything, prompt on unmatched subjects, approve only on request, deny unmatched subjects). The rule is select, never create: a client may pick a mode the user’s configuration already defines but can never author a policy on the wire, because a constructible policy surface would make every client an administrator of the user’s safety settings. Exposing allowAll is safe only because of the spawn boundary — the client already holds the user’s ambient authority. The starting mode is declared with the optional mode field on session/start, which is also how a non-interactive run declares its policy: start in a default-deny mode and unmatched subjects fail their tool call with a typed, model-visible denial instead of parking the run.

Ceilings. Under the ephemeral profile the mode is bounded by a ceiling: the comparand is always the sealed startup snapshot, never the currently held mode, and the authority order is partial — onRequest is incomparable with both promptUnmatched and denyUnmatched, so “not above” is not the test. A request above or incomparable to the ceiling is rejected with the approval-mode-ceiling reason. See durability profiles.

Scopes. Every choice in the menu carries a scope naming the grant durability it creates: once, session (the session policy overlay), or locally-persistent (the workspace policy store). Session-grant equivalence is defined per kind — for example, session shell grants match only the byte-identical raw command, and a write grant does not imply read. Until a client has schema support for a subject kind, it must treat session-scope choices for that kind as opaque server policy and render the server’s rule preview verbatim.

Decision durability. The decision itself is durable before anything else moves: the terminal record is the fold’s source of truth, auto-approvals from session grants still write terminal records and appear as view events, and a failed persistence of a locally-persistent rule surfaces as a later approval/updated while the decision itself stands.

Approvals do not expire in v1: the runtime starts no timer, so a pending approval parks its turn indefinitely, blocks idle unload, and survives restarts. Headless clients that must not park forever declare a default-deny mode instead. User-input prompts are the honest asymmetry: they do time out, because the runtime durably records their auto-resolution deadline.

A host restart is recovery, not amnesia: on load, recovery replays the approval streams, closes each still-pending old identity, and durably opens a fresh request with a deterministic new approvalId and the same action payload — pre-crash answers are evidence, not authority, and policy stages are re-derived by the live backend. A decide replayed across the restart is safe: if its own terminal is in the log the replay returns the original ack, and otherwise it answers the idempotent already-resolved conflict for the old identity — its commandId can never migrate to the fresh successor. Protected writes recover on their own fail-closed path with no silently revalidated pre-crash grant, and protected-write decisions additionally require a live reviewer on the host, failing closed with approvalReviewerUnavailable when none exists. None of this section exists on an ephemeral host, where pending approvals simply end with the process.