Claude Code Auto-Mode Safeguards: Protocol Record¶
This note records the protocol surface for the Claude Code safeguards adapter: what is published, what is not, the conservative emission shape the router implements, and the verification completed against a real client.
What Claude Code sends¶
Claude Code v2.1.278+ (auto mode) marks eligible sessions with a safeguards request body field on the Anthropic Messages format (/v1/messages?beta=true). The field rides the session's own model requests; there is no separate classifier endpoint. A server that consumes the envelope is expected to author safeguard_results, a response and streaming field that must reach the client unmodified.
Officially published (usable as contract)¶
safeguardsis a request body field on the Anthropic Messages format. Claude Code v2.1.278+ sends it in eligible configurations as part of the session's own model requests.safeguard_resultsis a response/streaming field that must reach the client unmodified.- Tool-use IDs (
tool_use.id,tool_result.tool_use_id) must not be rewritten. - Per-action fallback is documented client behavior: an individual action the server could not check does not trigger the ineligibility notice; Claude Code handles that action on its own and asks the server again on the next request. Emitting no result for an action is therefore safe by design on the client side. The notice appears only when the server's checks have stopped reaching the session for the rest of it.
- Beta pairing rules: capabilities pair a beta header with body fields; stripping one half produces hard 400 errors, both halves absent together turns the feature quietly off, and a gateway that rewrites or redacts request bodies for content inspection breaks the pairing the same way stripping does, so inspect without modifying.
- Header contract: forward
anthropic-versionandanthropic-betaverbatim; never allowlist beta values (the set changes with releases); cover repeated header values. Response headers:content-type(text/event-streamon streamed responses),retry-after(integer seconds),x-should-retrypassthrough,request-id(correlation), andanthropic-ratelimit-unified-*forwarded unchanged on every response. - Streaming: do not buffer complete responses before relaying; forward keep-alive pings and comment lines; Claude Code aborts a stream silent for 300 seconds by default.
- Client-side opt-outs to document, never to set automatically:
CLAUDE_CODE_AUTO_MODE_SERVER=0(temporary client-side gateway workaround, retains billed local classifier calls) andCLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1(turns server checks off whenAUTO_MODE_SERVERis unset). Client verification procedure:/status, then the "Auto mode server" row readsEnabledwhile server checks decide the session's actions. - Sources read 2026-09-21:
code.claude.com/docs/en/auto-mode-classifier-billing,code.claude.com/docs/en/llm-gateway-protocol,platform.claude.com/docs/en/api/beta-headers(no safeguards-related beta documented there), andcode.claude.com/docs/en/claude-apps-gateway(feature-availability table only; no wire schema).
Not published (still true)¶
The complete wire schema of safeguards and safeguard_results is not published by any official page. The beta headers reference lists no safeguards beta by that name (the envelope pairs with dangerous-tool-use-2026-09-03; see the capture below). The shapes in the next section are pinned by capture, not by a published reference, so a future Claude Code release can move them.
The captured wire schema (2026-09-22)¶
A real session verified the adapter against the live wire:
- Client:
claude-cli/2.1.280(the v2.1.278+ floor, satisfied),claude -pwith--permission-mode auto, modelclaude-sonnet-4-6(auto-mode-eligible on the Anthropic API). - Path: Claude Code → Router (
ANTHROPIC_BASE_URLat the router's/anthropicbase) → byte-faithful capture proxy → official Anthropic API. The capture sits between the Router and the API, so every shape below is what crossed the Router's native passthrough in both directions. - Server checks reached the session: the responses' check status read
availablewithoutcome: "not_flagged", and the session's stderr held no ineligibility notice. The native passthrough is verified compatible with the server-side checks.
The request envelope (captured)¶
safeguards is an array of per-check entries, not an object. One entry was captured, a dangerous-tool-use check:
"safeguards": [
{
"type": "dangerous_tool_use",
"classifier_context": {
"artifact_consent_holdback": false,
"auto_mode": {"allow": [], "environment": [], "hard_deny": [], "soft_deny": []},
"case_insensitive_paths": false,
"classify_all_shell": false,
"git_state": {"branch": null, "cwd": "...", "default_branch": null,
"error": "not_a_repo", "root": null, "status": null,
"visibility": {"origin": null, "push_remote": null,
"remotes": [], "visibility_cache": []}},
"home_dir": "...",
"is_remote_mode": false,
"live_cwd": "...",
"permission_mode": "auto",
"platform": "linux",
"restricted": false,
"rule_roots": {"cliArg": "...", "command": "...", "flagSettings": "...",
"hostCredential": "...", "localSettings": "...",
"mcpServerPolicy": "...", "policySettings": "...",
"projectSettings": "...", "session": "...",
"toolsNarrowing": "...", "userSettings": "..."},
"rules": {"allow": [], "ask": [], "deny": []},
"trusted_directories": {"additional": [],
"block_reads_outside_working_directories": false,
"network": [],
"primary": {"path": "...", "resolved": ["..."]}},
"user_identity": "...",
"v": 1
}
}
]
Additional captured facts about the request side:
- Beta pairing: the envelope rode
anthropic-beta: dangerous-tool-use-2026-09-03. The full captured set wasclaude-code-20250219, interleaved-thinking-2025-05-14, thinking-token-count-2026-05-13, context-management-2025-06-27, prompt-caching-scope-2026-01-05, effort-2025-11-24, dangerous-tool-use-2026-09-03, afk-mode-2026-01-31, and the router forwarded it verbatim. - Two model requests per turn with identical messages: one streaming (the main turn) and one non-streaming (the classifier check). Both carry the same
safeguardsarray. - The envelope rides top-level. The router's unknown-field extension map preserves it verbatim, explicit nulls included; the passthrough fixtures in
tests/anthropic/anthropic_safeguards_passthrough_test.rsnow carry the captured shape. - No unknown fields nested inside known-type content blocks appeared. The captured blocks carried only fields the DTO already knows: text
{type, text, cache_control?}, thinking{type, thinking, signature}, tool_use{type, id, name, input}, tool_result{type, tool_use_id, content, is_error?, cache_control?}. Byte-level retention of unknown fields inside known blocks is therefore not required by the captured schema; the DTO's behavior stands as implemented. - Another top-level unknown field rode the same requests:
context_managementwith{"edits": [{"keep": "all", "type": "clear_thinking_20251015"}]}. The same extension map preserves it. metadata.user_idis a JSON string carrying a stringified object (device, account, and session identifiers).
The response envelope (captured)¶
Non-streaming responses carried safeguard_results as an array of per-check entries correlated by tool-use id:
"safeguard_results": [
{
"type": "dangerous_tool_use",
"status": {
"type": "available",
"tool_uses": {
"toolu_01...": {"type": "evaluated", "outcome": "not_flagged"}
}
}
}
]
Additional captured facts about the response side:
status.typeread"available"when the check reached the server, andoutcome: "not_flagged"marked the allow-equivalent. An emptytool_usesmap appeared when the check had no tool use to correlate.- The response also carried
context_management: {"applied_edits": []}at top level. - The streaming (main-turn) responses carried no
safeguard_resultsevent. The results rode the non-streaming classifier-check responses. A streamingsafeguard_resultsevent is therefore unobserved, not disproven. - The responses also carried fields the router's response DTO does not model (
container,stop_details,inference_geo,usage.output_tokens_details,usage.cache_creation): all relayed unchanged, because the native non-streaming path relays the raw response body and the streaming path forwards each event's data string verbatim.
What the capture does not pin¶
Only outcome: "not_flagged" and status.type: "available" were observed. The deny equivalent of the outcome vocabulary, the unavailable/indeterminate equivalents of the status vocabulary, and a streaming safeguard_results event were not captured. The adapter's emission (next section) aligns to the captured vocabulary and records an explicit decision for each unobserved part.
What the router implements¶
The native path needs no schema knowledge¶
"Forward unchanged" is the contract, and that is the fully-specified, fully-testable core. A request carrying safeguards to a native Anthropic backend is forwarded with the envelope intact, and a response the backend authored under safeguard_results is relayed to the client untouched. No safeguards configuration is required.
Concretely, the router retains unknown top-level request fields in a general extension map on the typed request (plus the output configuration and system text blocks), and content blocks carry an unknown-block catch-all that preserves unrecognized block types verbatim. The capture confirmed the real client exercises none of the catch-all today: every field the session sent inside known block types is one the DTO already models.
The adapter is conservative and fail-closed¶
For a non-native (conversion) backend, the default is today's behavior: the request is served, nothing is synthesized, and no error is raised. Only an operator who explicitly configures safeguards.action_safety turns the adapter on. The router then evaluates the tool actions a conversion-backend response proposes through the pinned evaluator backend and attaches the verdicts.
Both conversion surfaces are covered: the Anthropic Messages conversion arms and the /v1/responses conversion arm, where the request posts to /v1/chat/completions with a responses_only model and the proposed actions arrive as function.arguments JSON strings. On the /v1/responses streaming arm, the results event is emitted before the [DONE] sentinel, matching the ordering guarantee the Messages arm makes for message_stop.
Failure handling and reason isolation follow three rules:
- Fail-closed outcomes: Every failure mode maps to a non-allow outcome. An unavailable evaluator, a timeout, malformed or unknown verdict output, a refusal, and budget exhaustion all resolve to
indeterminate, never to a synthesizedallow. - Scope of enforcement: The router does not execute the proposed tool and does not replace the client's final permission enforcement.
- Server-side reason isolation: The evaluator's
reasonnever reaches the client (see the emission shape below). On an evaluator transport failure, even the server-side verdict reason is the fixed stringevaluator transport failure, while the underlying error text (which embeds the pinned evaluator backend's URL) appears only in the warning log.
The System One evaluator is advisory by default¶
evaluator: systemone maps a System One decision battery (action as approve / confirm / block, plus irreversible and injected_instruction) onto the same fail-closed verdicts:
- Verdict mapping:
allow: Emitted only from anapprovechoice that clearsallow_thresholdandmin_confidence, withinjected_instructionbelowdeny_threshold.deny: Emitted from ablockprobability or aninjected_instructionat or abovedeny_threshold.indeterminate: Emitted forconfirmand every evaluator failure.
- Operating modes: Its default
advisorymode emits the chat-model evaluator's verdict (orindeterminate) and records disagreements insafeguards_action_safety_advisory_total.enforcemode emits the mapped System One verdict directly. Whenadvisorymode runs with no chat-modelbackendpinned, every action resolves toindeterminate, so the check always readsunavailableon the wire. - Decision state inputs: The decision state adds the user's latest request and up to three recent tool results to the tool name and arguments, never the system prompt. A user turn that is a tool round trip is not taken as the request, and Claude Code's
<system-reminder>blocks are skipped.injected_instructionacts as a detector rather than a boundary, and the emission shape below applies unchanged.
The adapter's emission shape follows the capture¶
For conversion backends, the adapter emits safeguard_results in the captured shape: a top-level array holding one dangerous_tool_use check entry whose status.tool_uses map is keyed by the wire id of each proposed tool use (the Anthropic tool_use.id on the Messages arms, the tool_calls[].id on the /v1/responses bridge). An evaluator allow is structurally identical to the entry the native backend authored in the capture:
"safeguard_results": [
{
"type": "dangerous_tool_use",
"status": {
"type": "available",
"tool_uses": {
"toolu_01...": {"type": "evaluated", "outcome": "not_flagged"},
"toolu_02...": {"type": "evaluated", "outcome": "flagged"}
}
}
}
]
The capture pinned only the allow mapping. Each other mapping is a decision, recorded here so a later capture can confirm or move it:
| Evaluator result | Emitted | Status |
|---|---|---|
allow | {"type": "evaluated", "outcome": "not_flagged"} under status.tool_uses[<id>] | Observed |
deny | {"type": "evaluated", "outcome": "flagged"} under status.tool_uses[<id>] | Unobserved, chosen |
indeterminate (timeout, unreachable evaluator, malformed verdict, budget exhaustion, unassembled or oversized arguments) | status: {"type": "unavailable"}, no tool_uses map | Unobserved, chosen |
| Streaming, Anthropic Messages | delta.safeguard_results on the turn's last message_delta | Read by the client (claude-cli 2.1.305, issue #2682) |
Streaming, /v1/responses bridge | SSE event safeguard_results before [DONE], data {"type": "safeguard_results", "safeguard_results": [...]} | Unobserved, chosen |
| Reply with no tool use | status: {"type": "available", "tool_uses": {}} | Required by the client (issue #2682) |
reason | Not emitted; logged at debug level (capped) | Decision |
Mixed responses resolve to unavailable for the whole check. When a response proposes several tool uses and any one of them resolves indeterminate, the entry reads status: {"type": "unavailable"} even if the others were evaluated. The alternative, available with a partial tool_uses map, leaves the client to guess what a missing id means (unevaluated or cleared), and the adapter must never let a tool use read as cleared when it was not judged. Because unavailable has a single unambiguous meaning, three intentional behaviors follow:
- A response with more tool uses than
max_actions_per_requestalways readsunavailable. - A
denynext to anindeterminateis not visible on the wire (it remains in the logs). - A tool use without an id, or with an id shared by another tool use in the same response, cannot be correlated and resolves to
unavailable.
On Anthropic Messages streams the verdicts ride on the last message_delta. The adapter first emitted them as a standalone safeguard_results SSE event before message_stop, an unobserved choice. claude-cli 2.1.305 skips that event: its stream handler reads streamed verdicts only from message_delta.delta.safeguard_results, and a request still waiting at message_stop settles as "no result" (issue #2682). The adapter therefore holds the terminal message_delta and message_stop while the evaluation runs and sets the same array the non-streaming arm attaches on that message_delta's delta. A stream that ends without a message_delta carries no verdicts, because inventing one would also overwrite the client's usage counts. The Chat Completions bridge has no message_delta, so it keeps the standalone event before [DONE].
A reply with no tool use still carries the check. claude-cli treats a reply without safeguard_results as the server declining to check and moves the whole session to its own billed classifier, with the "isn't eligible" notice. When the adapter is active, a reply that proposes no tool use therefore carries status: {"type": "available", "tool_uses": {}}: the check ran and had nothing to judge. Nothing is dispatched to the evaluator for it.
The reason stays server-side. The captured vocabulary has no field for a free-text reason, so the evaluator's reason (untrusted model output, capped at 4 KiB when parsed) is not sent to the client. It is logged at debug level next to the verdict instead (capped), and the verdict itself still feeds safeguards_action_safety_advisory_total in System One advisory mode. At the default info level the reason is therefore not recorded at all; an operator who needs it raises the level for the router. It stays below info on purpose: the reason is untrusted model output and may quote the tool arguments it judged.
The adapter stays conservative (config-gated, fail-closed, only for conversion backends). The captured shape makes its emission structurally compatible with what a native backend returns; the unobserved mappings above are what a future capture should confirm.
The native passthrough needs no adjustment: both directions relay the captured schema unchanged, pinned by tests whose fixtures carry the captured shapes.
Completing verification (executed 2026-09-22)¶
The trace-capture verification ran as follows:
- A native Anthropic backend (the official API) was configured and the session's request and response traces were captured between the Router and the API.
- The captured
safeguardsrequest envelope matched the extension map's retained fields; nothing load-bearing was nested inside known structures. - The captured
safeguard_resultspayloads were matched against the adapter's emission shape, and the passthrough test fixtures carry the captured schema. - The beta pairing was re-reviewed from the capture: the envelope pairs with
dangerous-tool-use-2026-09-03, and the router forwards the client's fullanthropic-betaset verbatim.
The native path's status is "verified against a real client"; the adapter's synthesized emission is "aligned to the captured shape, with the deny, unavailable, and streaming mappings unobserved".