Skip to content

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)

  1. safeguards is 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.
  2. safeguard_results is a response/streaming field that must reach the client unmodified.
  3. Tool-use IDs (tool_use.id, tool_result.tool_use_id) must not be rewritten.
  4. 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.
  5. 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.
  6. Header contract: forward anthropic-version and anthropic-beta verbatim; never allowlist beta values (the set changes with releases); cover repeated header values. Response headers: content-type (text/event-stream on streamed responses), retry-after (integer seconds), x-should-retry passthrough, request-id (correlation), and anthropic-ratelimit-unified-* forwarded unchanged on every response.
  7. 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.
  8. 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) and CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (turns server checks off when AUTO_MODE_SERVER is unset). Client verification procedure: /status, then the "Auto mode server" row reads Enabled while server checks decide the session's actions.
  9. 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), and code.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:

  1. Client: claude-cli/2.1.280 (the v2.1.278+ floor, satisfied), claude -p with --permission-mode auto, model claude-sonnet-4-6 (auto-mode-eligible on the Anthropic API).
  2. Path: Claude Code → Router (ANTHROPIC_BASE_URL at the router's /anthropic base) → 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.
  3. Server checks reached the session: the responses' check status read available with outcome: "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:

  1. Beta pairing: the envelope rode anthropic-beta: dangerous-tool-use-2026-09-03. The full captured set was claude-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.
  2. Two model requests per turn with identical messages: one streaming (the main turn) and one non-streaming (the classifier check). Both carry the same safeguards array.
  3. 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.rs now carry the captured shape.
  4. 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.
  5. Another top-level unknown field rode the same requests: context_management with {"edits": [{"keep": "all", "type": "clear_thinking_20251015"}]}. The same extension map preserves it.
  6. metadata.user_id is 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:

  1. status.type read "available" when the check reached the server, and outcome: "not_flagged" marked the allow-equivalent. An empty tool_uses map appeared when the check had no tool use to correlate.
  2. The response also carried context_management: {"applied_edits": []} at top level.
  3. The streaming (main-turn) responses carried no safeguard_results event. The results rode the non-streaming classifier-check responses. A streaming safeguard_results event is therefore unobserved, not disproven.
  4. 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 synthesized allow.
  • 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 reason never reaches the client (see the emission shape below). On an evaluator transport failure, even the server-side verdict reason is the fixed string evaluator 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 an approve choice that clears allow_threshold and min_confidence, with injected_instruction below deny_threshold.
    • deny: Emitted from a block probability or an injected_instruction at or above deny_threshold.
    • indeterminate: Emitted for confirm and every evaluator failure.
  • Operating modes: Its default advisory mode emits the chat-model evaluator's verdict (or indeterminate) and records disagreements in safeguards_action_safety_advisory_total. enforce mode emits the mapped System One verdict directly. When advisory mode runs with no chat-model backend pinned, every action resolves to indeterminate, so the check always reads unavailable on 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_instruction acts 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_request always reads unavailable.
  • A deny next to an indeterminate is 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:

  1. 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.
  2. The captured safeguards request envelope matched the extension map's retained fields; nothing load-bearing was nested inside known structures.
  3. The captured safeguard_results payloads were matched against the adapter's emission shape, and the passthrough test fixtures carry the captured schema.
  4. 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 full anthropic-beta set 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".