SwitchboardSBSwitchboardreference · the exit event
reference

The exit event.

A completion gate returns a decision and a reason the next turn can act on. Switchboard’s local source implements that connection for Claude Code. The public npm release is 0.1.0-alpha.5; the alpha.6 hook is an unreleased source preview. This reference shows the implementation, its limits, and a dated comparison of nine harnesses.

01

The mapping

source preview · not published

A gate label has six states. The event it is read at asks one question: stop the turn, or let it end. These are the response shapes the source implementation emits for each label, rendered from the same projection module the gate runs — the labels and their order come from gateStateLabelSchema.options, not from this page.

gate_labelhook responsestops the turnwhy
acceptedexit 0, no outputnoThe gate accepted the work against the recorded evidence. The projection adds nothing to the turn.
accepted-with-cautionsexit 0, with hookSpecificOutput.additionalContextnoThe turn ends. Cautions and unresolved items ride back as context instead of as a block, so the next turn starts holding them.
held-for-review{"decision":"block","reason":"…"}yesA person has to look at this before it counts as finished. The reason carries the gate's basis, the weakly-backed claims, and the bounded next objective.
blocked-missing-evidence{"decision":"block","reason":"…"}yesCompletion was claimed with no verification run and no evidence recorded. The reason names what is missing.
pendingexit 0, no blocknoDeclines to block: assessment has not finished, so no claim has been graded and there is no finding to return.
not-checkedexit 0, no blocknoDeclines to block: no check was run, so there is no finding to return.

Two rows decline to block on purpose. pending and not-checked both mean nothing was checked, and a check that has not run has no authority to stop a turn.

The source hook’s response layer follows this table with one documented exception: after 3 consecutive blocked stops in a single session it reports the unresolved state as additionalContext instead of blocking again. Receipts keep the gate’s true label.

02

The wiring

alpha.6 source · not in npm latest

The local alpha.6 installer writes the three hook groups below into Claude Code’s settings.local.json. Its recorder captures command results and file edits; the Stop gate grades the recorded evidence. Installing switchboard-cli from npm currently gives you the seven-command workflow, without these hook commands.

This is a source reference, not an installation recipe. The hook code lives in packages/cli/src/hook/. A release must be published and checked before this page can offer a hook quickstart.

{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Bash|Edit|Write",
      "hooks": [{ "type": "command", "command": "sb hook record", "timeout": 10 }]
    }],
    "PostToolUseFailure": [{
      "matcher": "Bash|Edit|Write",
      "hooks": [{ "type": "command", "command": "sb hook record", "timeout": 10 }]
    }],
    "Stop": [{
      "hooks": [{ "type": "command", "command": "sb hook gate", "timeout": 30 }]
    }]
  }
}
hooks
The source installer merges these entries into .claude/settings.local.json and preserves existing hooks.
Stop
Fires when the main agent has finished responding. It does not fire when the user interrupts, and API errors fire StopFailure instead.
PostToolUse / PostToolUseFailure
The recorder captures command results and file edits for this session. Both success and failure events are recorded.
matcher
The recorder listens to Bash, Edit and Write. The Stop gate has no matcher.
"type": "command"
A shell command. Anthropic's own recommendation for production is quoted below; the agent handler type is the one it warns about.
"command"
These commands are implemented in the local alpha.6 source. They are not available in the current public npm release.
"timeout"
The source uses 10 seconds for the recorder and 30 seconds for the Stop gate. Grading reads the session record without re-running the work.
Anthropic’s warning on the agent handler type
Agent hooks are experimental. Behavior and configuration may change in future releases. For production workflows, prefer command hooks.
Claude Code hooks guide, Agent-based hooks ↗
block cap · 8 consecutive blocks

Claude Code overrides a blocking Stop hook and ends the turn after 8 consecutive blocks. The hook is told it is already inside a continuation through stop_hook_active, which is true when Claude Code is already continuing as a result of a stop hook. A gate mounted on Stop can hold a turn open for a bounded number of retries; it cannot refuse permanently.

hooks guide, Stop hook hits the block cap ↗
version floor · v2.1.214

Before Claude Code v2.1.214, a hook that exited 2 while printing JSON that failed schema validation was treated as a non-blocking error and the action proceeded. A gate that intends to fail closed needs the later behaviour, so v2.1.214 is the floor for the shape above.

hooks reference, exit codes ↗
source implementation

Hook handlers exist in the local alpha.6 source. The website renders a pure projection of an issued fixture receipt; it does not run a hook or connect to an agent session. There is no Switchboard MCP server or plugin.

apps/web/src/lib/exit-decision.ts
03

What the harnesses do

checked on 25 July 2026

Every cell was read off the linked vendor document on that date. not verified means the document did not answer the question — it is not a finding of no.

harnesscan it refuse completioncan the check run outside the buildermachine-readable claimed-vs-checked recordwhat survives the event
Claude CodeCLI, settings.jsonStop and SubagentStop take {"decision":"block","reason":"…"}. TaskCompleted blocks on exit code 2, or on {"continue":false,"stopReason":"…"}. Claude Code overrides the hook and ends the turn after 8 consecutive blocks.hooks reference ↗Handler types include type:"prompt" and type:"agent" — an agent handler spawns a subagent with tool access to check the condition. Agent hooks are documented as experimental.hooks reference ↗None in the hooks reference. Stop hooks receive last_assistant_message — the text of the claim, not a list of claims with their evidence.hooks reference ↗The reason returns to the model as feedback for the next turn. The session transcript at transcript_path keeps it as conversation text.hooks reference ↗
Codex CLI~/.codex/hooks.jsonStop takes {"decision":"block","reason":"…"}, or exit code 2 with the continuation reason on stderr.hooks docs ↗Only type:"command" handlers run today. prompt and agent handlers are parsed and skipped.hooks docs ↗None in the hooks docs.hooks docs ↗The reason continues the model from the hook-provided message. No cap on repeated blocks is documented.hooks docs ↗
Cursor.cursor/hooks.jsonNo. The stop hook returns followup_message and nothing else; Cursor submits it as the next user message. The default loop_limit is 5 auto follow-ups per script.hooks docs ↗No. Hooks are scripts Cursor executes.hooks docs ↗None in the hooks docs.hooks docs ↗The follow-up becomes a user message. Hook failures — crash, timeout, invalid JSON — allow the action through unless failClosed is set.hooks docs ↗
Gemini CLIsettings.jsonAfterAgent takes decision:"deny", which rejects the response and forces a retry turn.hooks reference ↗No. The type field accepts "command" only.hooks reference ↗None in the hooks reference.hooks reference ↗The reason returns to the agent as feedback. No retry cap is documented.hooks reference ↗
OpenHands.openhands/hooks.jsonThe stop event takes {"decision":"deny","reason":"…"} and the agent keeps working.hooks docs ↗No. Hooks are commands with a timeout.hooks docs ↗None in the hooks docs.hooks docs ↗The reason continues the conversation. No cap on repeated denials is documented.hooks docs ↗
Ampplugin APINo. agent.end fires when the agent has finished handling a prompt; the handler can return { action: 'continue', userMessage } to start a new turn, which is not a veto of the one that ended.plugin API ↗No. Plugins are code the harness loads.plugin API ↗None in the plugin API.plugin API ↗The userMessage becomes the next turn's prompt.plugin API ↗
GitHub Copilotcloud agent + CLI, .github/hooks/*.jsonagentStop and subagentStop take decision:"block", which forces another agent turn using reason. Hooks are available for the Copilot cloud agent and for Copilot CLI.hooks reference ↗No. Handler types are command, HTTP and prompt. A prompt handler auto-submits text as if the user typed it and is supported on sessionStart only, so nothing evaluates agentStop in a context of its own.hooks reference ↗None in the hooks reference.hooks reference ↗The reason forces another agent turn.hooks reference ↗
DevinCLI only — .devin/hooks.v1.jsonStop fires when the agent wants to stop, and a hook returns decision "approve" or "block". The document covers Devin CLI; it does not describe the cloud agent.hooks overview ↗Handler type is "command" to run a shell command, or "prompt" to evaluate an LLM prompt.hooks overview ↗not verified — the hooks overview does not address it.hooks overview ↗not verified — the hooks overview does not say where the block reason goes.hooks overview ↗
AiderCLI flagsNo hook system and no completion event. --auto-test runs --test-cmd after changes and feeds problems back; there is no event to subscribe to and no decision field.options reference ↗No. The test command is a command.options reference ↗None in the options reference.options reference ↗.aider.chat.history.md — a markdown log of the conversation.FAQ ↗

The record column has one answer everywhere it resolved: none. In every harness that can refuse and whose last column resolved, the reason is returned to the model as conversation text, and any copy that persists is part of the session transcript.

04

Limits

what is not built
  1. The unpublished alpha.6 source implements sb hook install, which wires a PostToolUse recorder and a Stop-hook gate, and grading runs through the exported core functions. Its scope stays narrow on purpose — it grades this session's recorded commands and file edits only, against a synthesized session contract when no contract.yaml exists, and every receipt discloses that synthesis. There is still no MCP server and no plugin.

    packages/cli/src/hook/gate.tspackages/cli/src/hook/record.ts

  2. The .claude/settings.example.json emitted by @switchboard/projections is four keys — switchboard_projection, note, mission_boundary, return_when. None of them is a Claude Code settings key, and the file configures nothing.

    packages/projections/src/claude/index.ts:150

  3. sb ingest has three preconditions: a non-empty active_handoff_id in working state, a return whose handoff_id equals it, and a return whose objective_attempted matches the handoff's loop objective. The output of createReturnTemplate satisfies all three untouched, because it is built from the handoff. That state is produced by the sb run claude dispatch path, which CLAUDE.md lists as depreciated.

    packages/core/src/ingest.ts:80packages/core/src/handoff.ts:68

  4. The boundary check reads the agent's own words. checkBoundaries builds its search text from the return's summary and decisions_made and matches that against the contract's non_goals. It never reads changed_files, file paths or the diff, so a non-goal the agent does not mention is recorded as preserved whatever the diff shows. The case study renders a canonical delta that carries the tests/ violation; nothing in the shipped path inspects the repository to find one.

    packages/core/src/canonical-delta.ts:65

  5. sb receipt exits 0 even when the receipt carries closure_allowed false. In the unpublished alpha.6 source, sb evaluate --receipt and --latest exit 1 when the evaluation lane's reentry signal is block_recommended. held-for-review receipts evaluate to hold_recommended and still exit 0. These flags are not part of the current npm release.

    packages/cli/src/commands/receipt.ts:266packages/cli/src/commands/evaluate.ts:102

  6. ProjectMode is unwired at the CLI. sb receipt passes projectModeAtIssuance null and scoreStackAtIssuance null, so deriveScoreReportingLevel stamps score_reporting_level narrative_only on every CLI-issued receipt.

    packages/cli/src/commands/receipt.ts:173packages/core/src/canonical-pack.ts:657

  7. FULL_RUN_AVAILABLE is false. classifyScope downgrades a requested full-run to slice-check, and checkPromotionGate then returns promotion_allowed false for closure_seal and for milestone_review.

    packages/core/src/eval-scope.ts:51packages/core/src/eval-scope.ts:176

  8. score_reporting_level "complete" and verdict "disputed" are declared in the enums and returned by nothing. deriveScoreReportingLevel returns narrative_only or partial and never complete. deriveReceiptVerdict returns verified, partially_verified, inconclusive or not_verified and never disputed.

    packages/core/src/canonical-pack.ts:657packages/core/src/receipt-v2.ts:209

  9. The evaluation lane runs in the same process as the work it grades. evaluator_role "independent" is a zod literal written onto the record — a constraint on the document, not a sandbox around the evaluator.

    packages/core/src/evaluation-lane.ts:657

  10. No signing and no issuer identity. Receipt ids are random — rcv2- followed by eight random bytes — and content hashes are the first 16 hex characters of a sha256 digest.

    packages/core/src/receipt-v2.ts:754packages/core/src/canonical-pack.ts:169

  11. sb receipt is not idempotent. Each run over the same ingest mints a new receipt_id and writes a new file.

    packages/cli/src/commands/receipt.ts:164packages/core/src/receipt-v2.ts:754

Named next slice

A one-line core fix: the canonical delta's gap template renders the sentence “Objective only no met.” when objective_met is “no”, and that sentence can appear in source-generated block reasons and blocked receipts. Core was read-only in the hook slice, so the debt is visible rather than hidden. It is a packages/core change.

packages/core/src/canonical-delta.ts:320

Structural guarantees, and how far each one goes

guaranteestatuswhat that means herepath
No re-executionshippedrunEvaluation reads the receipt and the recorded delta. It never runs the work again.packages/core/src/evaluation-lane.ts
No write accessshippedThe lane module imports crypto and types. No fs, no child_process; it returns a result object and writes nothing.packages/core/src/evaluation-lane.ts:1
Evaluator is not the builderdeclaredevaluator_role is a zod literal on the evaluation result. The record states the separation; nothing enforces it at runtime.packages/core/src/evaluation-lane.ts:657
Separate invocation for hook receiptssource onlyThe unpublished alpha.6 source adds sb evaluate --receipt: a separate CLI invocation reading the receipt and proof manifest from disk. This does not create an independent model or a sandbox around the evaluator.packages/cli/src/commands/evaluate.ts:102
Different model familydesigned — not shippedNo model runs in the lane at all today. It is deterministic code over the record.packages/core/src/evaluation-lane.ts
on the word receipt

Signed action-receipt specifications use the word differently: an Agent Receipt is an Ed25519-signed, hash-chained record that a single action occurred, while a Switchboard receipt is a self-issued, unsigned assessment of the claims made about work that was performed.

the bet against this page

Bet 04 on the forecast scoreboard is that native receipts or attestation land in a major harness. If that happens the mapping above stops being worth building, and the concession is on the record with a grading date.

read the forecast →