Skip to main content
Working with an agent? Give them a link to this page as markdown.

Gateway hooks

Gateway hooks let a gateway or agent harness send conversations to the Watcher API. You do not need to install the Watcher client on developer machines. The resulting sessions, tool-call judgments, and grades appear in the Analyzer.

EndpointWhen to use itWho can enforce the result
POST /api/v1/hooks/pre-tool-useSend a structured conversation for enforcement, observation, or ingestion only.In enforce mode, your integration holds the call. It applies Watcher's returned decision.
POST /api/v1/hooks/apertureAperture sends a completed supported coding-agent request and response.Nobody through this hook. It records advisory judgments after the response.

Authentication and reachability​

On Apollo cloud, use https://app.apolloresearch.ai/api as the API base URL. For self-hosting, use your deployment's Analyzer hostname with /api. The gateway must reach that hostname over HTTPS on TCP port 443. For a private self-hosted deployment, configure routing from the gateway to the Watcher host. Configure DNS and firewall rules.

In SSO mode, send an organization API key in x-api-key. Attributing sessions to another developer requires sessions:write:any on any hook. Aperture always supplies another developer's identity through metadata.login_name. Therefore, sessions:write:any is mandatory. Its absence returns HTTP 403. Generic integrations can omit user and act as the authenticated caller. The optional user field is honored only with sessions:write:any. A service key with only sessions:ingest can record unattributed generic-hook sessions. This is session-write permission for unattributed sessions. A request whose external_ids name a Claude Code or Codex conversation records into that conversation's session under the ownership rules in Two recorders. When that session belongs to a different developer than the one the request is attributed to, the endpoint still returns its judgment but records nothing. Non-ephemeral enforce and observe requests record their judgments. Paused requests record only the transcript. In enforce and observe modes, callers without session-write permission still receive a completed judgment. However, the endpoint skips transcript and decision recording. Set ephemeral: true to evaluate without storing the transcript, decision, or grades. Usage accounting still applies.

In self-hosted proxy mode, Watcher does not accept organization API keys. Your reverse proxy must authenticate the gateway. It must pass an identity with session-write permission. Attributing sessions to another developer requires permission to write sessions for other users. Therefore, Aperture needs that permission. Aperture can send a configured credential as x-api-key. However, your proxy must validate it and supply that authorized identity. An interactive browser sign-in alone does not authenticate a gateway request. See API authentication and self-hosted authentication.

Enforcement modes​

The enforcement mode in the administrator's managed client settings document controls hook behavior through enforcement.mode. Administrator changes can take up to 60 seconds to reach a server worker's policy cache. An in-flight request keeps the policy snapshot it started with. This applies to all its inputs.

Administrator modeGeneric pre-tool-use hookAperture hook
enforceEvaluates the call. Returns an enforceable decision.Evaluates the call. Records an advisory judgment.
observeEvaluates the call. Returns its completed advisory judgment with decision: "deferred". Authorized, non-ephemeral requests record that judgment after the response.Evaluates the call. Records an advisory judgment.
pausedIngests the transcript without evaluating thresholds, rules, or monitors. Records no tool-call decision.Ingests the transcript without evaluating thresholds, rules, or monitors. Records no tool-call decision.

For example, an administrator can select observation in the managed document:

enforcement:
mode:
value: observe
permission: locked

Generic integrations can set enforcement.mode in settings_overrides. You can use either a YAML string or a JSON object. For example:

{"settings_overrides": {"enforcement": {"mode": "observe"}}}

The normal administrator permission rules apply. A locked setting rejects an override with HTTP 422. This happens even if it repeats the locked value. Omit the override to inherit that value. Watcher merges permitted overrides into the administrator's settings before evaluating the request. The override document is limited to 65,536 characters of YAML or serialized JSON. Oversized overrides return HTTP 422. Overrides that take too long to validate and merge also return HTTP 422.

The generic hook waits for evaluation before responding. This applies in both enforce and observe modes. The request's policy snapshot governs its judgments and subsequent recording. Recording runs after the response. It requires session-write permission and non-ephemeral input.

Aperture uses the administrator's settings. Its fixed webhook body does not carry settings_overrides. Its transport caps behavior at observe even under a locked enforce policy. It cannot block tool execution. Administrator paused still makes Aperture ingest-only. Aperture acknowledges admission before translating the capture and resolving policy. After evaluation finishes for all selected calls, Watcher ingests the transcript. It records the available judgments.

Paused hooks return an unjudged acknowledgement. They only ingest the transcript. This differs from the Watcher client's paused mode. The client still evaluates deterministic checks. Hook modes do not configure trailing review. Trailing review can separately review ingested sessions.

Tool names and policy​

Tool thresholds and command rules name Claude Code's tools, such as Bash, Read, Write and Edit. This holds for the built-in settings and usually for your own. Other agents give the same kinds of tool their own names and argument keys. Before evaluating thresholds and command rules, gateway hooks translate a call from a supported agent onto the matching Claude Code tool. The built-in policy and your Bash, Read or Write entries therefore apply to those agents' calls too. One entry for a Claude Code tool now covers every agent's matching tool: an always-allow pair on Read or Bash, for example, switches off that tool's rules for all of them, except an agent whose own exact entry (described below) supplies its thresholds.

Translation affects only thresholds and command rules. The stored tool call, the transcript and the input the graders review keep the agent's own tool name and arguments, so the Analyzer shows what the agent called. A translated call's recorded decision carries a note in its decision_context field, returned with a trajectory's tool-call decisions, for example Policy evaluated this run_shell_command call as Bash. The note names the Claude Code tool even when an entry keyed to the agent's own name supplied the thresholds. A decision without the note was evaluated under the agent's own tool name.

Which translations apply depends on the identified agent. The Aperture hook uses the agent whose markers matched (see Configure Aperture). An OpenCode user agent takes precedence over Claude Code or Codex system text in the same request, so such a capture uses the OpenCode translations. The generic hook uses the claude_code or codex key in external_ids; with neither key, tool names are evaluated as sent. The Watcher client does not translate: on the developer's machine, Claude Code and Codex report their tool calls under the names the built-in settings use.

AgentTools translated, as agent name → Claude Code nameLeft under the agent's own name
Claude Code, including Claude Coworkmcp__workspace__bash → Bash. Claude Code's own tools already use these names.Everything else.
Codexexec_command, shell, shell_command, local_shell → Bash; view_image → Read; update_plan → TodoWrite; spawn_agent, collaborationspawn_agent → Agent; send_message, collaborationsend_message → SendMessage; request_user_input, request_user_input_async → AskUserQuestion; tool_search → ToolSearchexec (Code Mode), js, write_stdin, and wait_agent, followup_task, list_agents, close_agent, interrupt_agent, wait, sleep, with or without a Codex tool-group prefix such as collaboration or clock. Names of the form mcp__<server>__<tool>, which Codex's hooks give MCP tools, even when <tool> is a translated tool's name. apply_patch already matches the built-in rules.
OpenCodebash → Bash; read, write, edit → Read, Write, Edit; glob, grep → Glob, Grep; webfetch, websearch → WebFetch, WebSearch; todowrite → TodoWrite; task → Agent; skill → Skill; lsp → LSP; question → AskUserQuestion; plan_exit → ExitPlanModelist, and the experimental code-mode tool execute. apply_patch already matches.
Gemini CLIrun_shell_command → Bash; read_file, write_file, replace → Read, Write, Edit; glob → Glob; grep_search, search_file_content → Grep; web_fetch → WebFetch; google_web_search → WebSearch; write_todos → TodoWrite; activate_skill → Skill; invoke_agent → Agent; ask_user → AskUserQuestion; enter_plan_mode, exit_plan_mode → EnterPlanMode, ExitPlanModelist_directory, read_many_files, save_memory, get_internal_docs.
Qwen Coderun_shell_command → Bash; read_file, write_file → Read, Write; edit, replace → Edit; glob → Glob; grep_search, search_file_content → Grep; web_fetch, web_search → WebFetch, WebSearch; todo_write → TodoWrite; agent, task → Agent; skill → Skill; ask_user_question → AskUserQuestion; enter_plan_mode, exit_plan_mode → EnterPlanMode, ExitPlanModeexec (code mode), list_directory, save_memory, and the scheduling, team and worktree tools.
Mistral Vibebash, git_bash → Bash; powershell → PowerShell; read_file, read, write_file → Read, Read, Write; edit, search_replace → Edit; grep → Grep; web_fetch, web_search → WebFetch, WebSearch; todo → TodoWrite; task → Agent; skill → Skill; ask_user_question → AskUserQuestion; exit_plan_mode → ExitPlanModeThe companion tools that read or feed a running shell session.
Antigravity CLIrun_command → Bash; view_file, write_to_file, replace_file_content → Read, Write, Edit; grep_search → Grep; find_by_name → Glob; read_url_content → WebFetch; search_web → WebSearchlist_dir, manage_task, ask_question, generate_image.

A tool is translated only where it does what the Claude Code tool does. A tool left under its own name is evaluated under that name, without translation: add threshold entries and rules under that name, as the Analyzer shows it. A tool with no entry of its own receives the unknown fallback pair.

Code-mode tools (Codex exec, Qwen Code exec, OpenCode execute) take a JavaScript program that calls the agent's other tools. The program is graded as one call on its text. The shell commands and file operations inside it are not evaluated as Bash, Read or Write calls, so the rules for those tools do not see them.

Codex reads files mostly through its shell tools, which translate to Bash. Read entries cover only Codex's view_image. Codex's spawn_agent and send_message are translated bare or in their default collaboration group; if Codex's configuration renames that group, they are evaluated under the renamed names.

Arguments are translated so that rules match what they expect:

  • A shell tool's command is matched as the Bash command text, whatever key the agent uses. A command sent as an argument list is joined into one shell command. A list of the form bash -c <script> contributes only the script, and only when the program is bash, sh or zsh, by name or under /bin or /usr/bin.
  • A working directory sent with a shell call (such as OpenCode's workdir) is never added to the command: rules always match the command as the agent wrote it, split at shell control operators such as ;, && and | as usual. A rule anchored with ^, such as a deny on ^rm -rf, matches the same way whatever directory was sent.
  • The directory only limits allow rules. It is trusted when it is a plain relative path inside the project, such as src or ./pkg/sub, and allow rules then apply as usual. For any other directory (absolute, home-relative, or leaving the project with ..), allow rules cannot approve the call, so it is graded (triage, then deep review) unless a deny or escalate-to-human rule decides it first. This guards against a relative read, such as cat passwd run in /etc, matching a read-only allow rule. Codex exec_command and Antigravity CLI calls usually carry an absolute working directory, so allow rules rarely approve them.
  • Arguments that change what runs without appearing in the command text have the same effect as an untrusted directory whenever they carry a value: an environment (Mistral Vibe env, Codex local_shell env), a shell executable override (Codex exec_command and Mistral Vibe shell), another execution environment (Codex exec_command environment_id), or a user to run as (Codex local_shell user). Allow rules cannot approve such a call; deny and escalate-to-human rules still apply.
  • A file tool's path is matched as the Read, Write or Edit path.

Entries keyed to an agent's own tool name still apply after translation, with one limit: rules keyed that way can no longer allow a call on their own.

  • A tool_thresholds entry whose key is exactly the agent's tool name, such as bash, takes precedence over the Claude Code tool's entry. If that entry always allows, always denies or always flags, it decides before any rule, including rules matching the agent's name. A glob pattern that matches only the agent's name never supplies a translated call's thresholds, even when the Claude Code tool has no entry of its own. If such a glob always allows, though, it still switches off rules keyed to names it matches, as any always-allow pair does for its tool's rules.
  • A command rule whose tool_name matches the agent's name, including a glob such as *, is also evaluated against the agent's own arguments. There, only its deny and escalate-to-human outcomes count: its allow and its send-to-grading outcomes are ignored. The most severe outcome wins, so such a deny beats a Bash allow that matches the same call.
  • Those deny and escalate-to-human outcomes apply even when the call's thresholds come from a Claude Code tool that always allows, such as the built-in Read. That pair switches off rules keyed to Read itself, for translated and Claude Code reads alike, but not rules matching the agent's name. So under the built-in thresholds a .env deny keyed to Read never fires, while one keyed to read_file fires for Gemini CLI reads. An always-allow entry keyed to read_file would switch that rule off too.
  • A rule whose tool_name also matches the Claude Code name, as * does, applies to the translated call as well, allow included. To allow a translated call, write the rule for the Claude Code name.

Generic pre-tool-use hook​

Send the conversation in top-level messages. Include messages through the assistant message containing the pending tool calls. input selects the calls to evaluate. You can send one object or a list of objects, one per call. A list can contain at most 32 inputs. Larger lists return HTTP 422 before grading or ingestion. Batches with more than one input must send messages at the top level. Each input must omit messages or set it to null or []. A nonempty nested transcript is rejected even when it matches the top-level transcript. Batch inputs must have distinct tool_use_id values.

A selected ID that appears in the transcript must identify the same tool name and arguments as the input. Object key order does not matter. However, JSON value types and string content must match. You may omit tool_input for a call whose arguments are {}. Explicit null is not an empty object. Repeated occurrences of that ID must all agree. Mismatches return HTTP 422 before grading or ingestion. Every non-ephemeral named input requires a matching transcript call. This applies even when the effective mode is paused. Ephemeral inputs may omit it.

Watcher evaluates calls concurrently. It returns results in input order. It ingests the shared transcript once. Do not include tool results that occur after the pending calls.

For transcript-only ingestion, supply messages. Set input to null or [], or omit input. The response is []. No mode override is needed. No tool-call ID is needed. The transcript may contain historical tool calls without requesting a judgment for them.

Save this body as hook-request.json, replacing the conversation ID, call ID, messages, and tool input with your integration's values:

{
"format": "watcher",
"messages": [
{"role": "user", "content": "Print hello."},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call-001", "function": "Bash", "arguments": {"command": "printf hello"}}
]
}
],
"input": {
"tool_name": "Bash",
"tool_input": {"command": "printf hello"},
"tool_use_id": "call-001"
},
"external_ids": {"other": "conversation-001"}
}

Set WATCHER_API_URL to your API base URL and WATCHER_API_KEY to your organization API key, then send it:

curl --fail-with-body \
-H "x-api-key: $WATCHER_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @hook-request.json \
"$WATCHER_API_URL/v1/hooks/pre-tool-use"

Multiple tool calls​

Select each pending call once. The output list follows input order, even when that differs from the order of tool calls in messages:

{
"format": "watcher",
"messages": [
{"role": "user", "content": "Read the two files."},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call-001", "function": "Read", "arguments": {"file_path": "/work/one.txt"}},
{"id": "call-002", "function": "Read", "arguments": {"file_path": "/work/two.txt"}}
]
}
],
"input": [
{"tool_name": "Read", "tool_input": {"file_path": "/work/two.txt"}, "tool_use_id": "call-002"},
{"tool_name": "Read", "tool_input": {"file_path": "/work/one.txt"}, "tool_use_id": "call-001"}
],
"external_ids": {"other": "conversation-002"}
}

For this request, output 0 has tool_use_id: "call-002" and output 1 has tool_use_id: "call-001". Handle each output separately as described below.

Transcript-only ingestion​

Send the transcript with no selected calls:

{
"format": "watcher",
"messages": [
{"role": "user", "content": "Hello."},
{"role": "assistant", "content": "Hello. How can I help?"}
],
"input": null,
"external_ids": {"other": "conversation-003"}
}

The response is HTTP 200 with []. Use the same curl command above with this body. Setting input to [] or omitting it has the same effect. messages is required when there are no inputs. The transcript is recorded after the response; no tool-call judgment or hook grade is created.

Reusing a completed evaluation​

By default, a hook request can reuse a completed evaluation. This applies for the same organization, session and tool-call ID. The selected tool name and arguments must match the stored call. Reuse requires known conversation IDs. It requires permission to read the resolved session. It requires authority to ingest it. For organization API keys, sessions:read:any permits this read. This includes unattributed sessions. It grants access to sessions across the organization. Signed-in users can read their own sessions without that permission. Ephemeral requests evaluate normally. Calls without a reusable record evaluate normally. Write-only API keys evaluate normally. A grading failure is not a reusable evaluation. An unjudged record is not a reusable evaluation.

Watcher applies the request's current enforcement mode to the stored judgment. For example, a judgment first recorded in observe mode can later produce an allow for an enforcing caller. It can produce a deny or escalation. Reuse creates no new grades. It creates no new evaluation record. The submitted transcript can still be ingested.

Send force_regrade: true to bypass reuse and evaluate again. The new evaluation can replace the stored decision. This applies even when the request observes rather than enforces. Rules still run. Paused mode still only ingests. Policy changes do not automatically invalidate a stored judgment. Use this option when a new evaluation is wanted.

Requests arriving together, or before a previous response has been recorded, can both grade the call. Prefer one monitoring integration per conversation: Watcher live does not yet use this endpoint, so combining it with gateway hooks can repeat grading and mix evidence from different attempts.

The Python SDK's pre_tool_use_hook(..., force_regrade=True) sends this option. pre_tool_use(..., force_regrade=True) also skips the SDK's local decision path. For ordinary local decisions, selected tool names and arguments are checked before returning a judgment; mismatches raise ValueError. Ambiguous copies of top-level and nested transcripts are sent to the server for validation.

Request fields​

FieldContract
formatRequired; use watcher.
messagesThe shared structured conversation. Required when input is null, empty, omitted, or contains more than one item. A scalar or one-item list may instead supply input.messages; if both locations are non-null, their transcripts must agree. Multiple inputs must omit nested messages or set it to null or [].
inputOne monitoring input, a list of inputs for the shared conversation, or null. Null, an empty list, or omission requests transcript ingestion only and returns [].
input.tool_name, input.tool_inputEach input object requires tool_name; tool_input supplies its arguments. Omission is allowed for a matching call with empty {} arguments.
input.tool_use_idA stable ID matching the selected tool's name and arguments in the shared messages. Required for every non-ephemeral named input, including paused requests. Batch IDs must be distinct; every transcript occurrence of a selected ID must agree. Ephemeral inputs may omit a corresponding transcript call.
external_idsConversation IDs keyed by claude_code, codex, aperture, or other. Required unless ephemeral: true. Include the harness key when known: a claude_code or codex key records the session as that agent, and a session with neither is recorded as other. If no ID exists, send an explicit null value such as {"claude_code": null}. The session stores an aperture value under the tailscale-aperture key, the same key the Aperture webhook uses for the session IDs it keeps.
userOptional developer email for callers authorized to write sessions for other users. Otherwise ignored.
force_regradefalse by default. Set to true to evaluate again instead of reusing a completed judgment. Does not enable grading in paused mode.
modefull by default. triage_only skips the gateway monitor after triage; a call triage does not clear returns an escalation judgment.
ephemeralfalse by default. When true, saves no transcript, tool-call decision, or tool-call grades. Usage data is retained. Enforce and observe still return a completed judgment.
settings_overridesOptional settings overlay as a YAML string or JSON object. Watcher merges it into the administrator's settings before evaluation. Set enforcement.mode here to change mode. Admin-locked paths and overrides exceeding 65,536 serialized characters are rejected with HTTP 422.
skip_rules, policy_versionFor integrations that already evaluate deterministic checks using Watcher's shared SDK evaluator. That evaluator uses tool names as sent, so skip_rules is ignored for a call the hook translates: the hook runs the checks again under the Claude Code name, and the thresholds it grades against are always the translated ones. The Python SDK's pre_tool_use already sends such calls to the hook without evaluating them locally. Other integrations should leave these unset.

Pre-rendered input.transcript and caller-supplied input.triage_assessment are rejected. Scalar inputs with an explicit empty tool_name are accepted for transcript-only ingestion and return one unjudged response object. Use null or an empty list for new integrations.

Supply stable conversation IDs whenever possible. Without them, Watcher reconciles by transcript prefix, so independent conversations with identical transcripts can initially share a session. Keep a call's ID stable when retrying so its recorded decision converges on the same call.

Handle the response​

A successful request returns HTTP 200. A scalar input returns one response; a list returns one output per input in the same order. Null or omitted input returns an empty list. Every output includes tool_use_id, echoing that input's ID or null when no ID was supplied. Apply each available decision field:

decisionCaller behavior
allowThe call may proceed.
denyDo not execute the call.
escalateObtain human approval before proceeding, or apply your integration's policy for unavailable approval.
deferredWatcher withholds enforcement. Continue through your integration's own permission flow.

In enforce or observe mode, a monitor refusal in a batch produces an error output in that input's position, while the other inputs continue. For example:

{"tool_use_id": "call-002", "status_code": 503, "detail": "Monitor temporarily unavailable"}

An error output has no decision. Handle its status as you would a scalar HTTP error, using your integration's failure policy. Successful judgments are still recorded; retry only failed inputs when your retry policy permits it. Input validation and settings errors reject the whole request before evaluation.

In enforce and observe modes, judged_decision reports the completed judgment. Observe always returns decision: "deferred", so the judgment is advisory. Outputs for paused calls and legacy unnamed inputs have a null judgment and decision_source: "bypass". Transcript-only requests with no inputs return []. verdict explains the result; triage and gateway contain stage results when those stages ran. enforcement_mode reports the effective mode.

Enforce and observe responses can take seconds to minutes. Configure your caller's timeout and what it does if Watcher cannot be reached. An HTTP 200 confirms the returned result, not completed transcript or decision storage. Background recording can fail without affecting the response, leaving a missing or incomplete audit in Analyzer.

HTTP 422 validation errors use a list of details and reject the whole request before ingestion or grading. A locked mode override identifies the settings field in that list:

{
"detail": [
{
"loc": ["body", "settings_overrides"],
"msg": "settings_overrides changes admin-locked settings: enforcement.mode",
"type": "value_error"
}
]
}

Configure Aperture​

For support levels by agent and provider, see Supported agents.

Prerequisite: your coding agent's requests already appear in Aperture's logs. Use Aperture's client setup guides to establish that connection before configuring this webhook.

For Codex, define a custom model provider in Codex's config.toml, as Aperture's Codex guide does, and set supports_websockets = false in it:

model_provider = "aperture"

[model_providers.aperture]
name = "Aperture"
base_url = "http://<aperture-hostname>/v1"
wire_api = "responses"
supports_websockets = false

Watcher supports Codex through Aperture over HTTP; WebSocket requests are not a supported route. A custom provider already uses HTTP when supports_websockets is unset; setting it makes that explicit. codex doctor reports the active provider's supports websockets value.

Codex's built-in OpenAI provider, which Codex uses when you point the openai_base_url key in config.toml at Aperture instead, first tries a WebSocket connection to /v1/responses. When Aperture refuses it, for example with HTTP 404, each new Codex session makes several failed attempts, warns Falling back from WebSockets to HTTPS transport, and then sends its requests over HTTP. Watcher records those HTTP requests as usual; the cost is a delay of several seconds before each session's first request.

This endpoint accepts Aperture's entire_request event in these formats:

FormatAdmitted coding-agent identity
Anthropic Messages, including the hosted variants detailed belowClaude Code or OpenCode, using the markers below.
OpenAI Responses, including OpenRouterCodex or OpenCode, using the markers below.
OpenAI Chat Completions, including OpenRouterCodex, OpenCode, Qwen Code or Mistral Vibe, using the markers below. Ordinary OpenAI SDK application traffic is excluded.
Gemini generateContent, including VertexAntigravity CLI or OpenCode, using the markers below. Gemini CLI captures are recorded without support, as described below.
Bedrock Converse and ConverseStreamOpenCode's user-agent marker below. Ordinary AWS SDK traffic is excluded.

Watcher records each admitted session as the agent whose markers matched, so the Analyzer shows and filters sessions by that agent. A capture that matches no supported agent is skipped with excluded_conversation, never recorded as other.

An Anthropic-format capture qualifies when any one of these Claude harness cues matches:

CueAccepted value
5a. User agentThe webhook envelope's metadata.user_agent starts with claude-cli/.
5b. CLI system textContains You are Claude Code, Anthropic's official CLI for Claude.
5c. Agent SDK system textContains You are a Claude agent, built on Anthropic's Claude Agent SDK. or You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK.
5z. Native body metadatarequest_body.metadata.user_id is a JSON string with string-valued device_id and account_uuid fields, and a canonical lowercase, hyphenated UUID session_id.

request_body.metadata belongs to the original Claude API request. It is separate from the webhook envelope's metadata, which Aperture adds. Watcher reads Claude's native conversation ID from the JSON string in request_body.metadata.user_id, rather than from an HTTP header or Aperture's grouping ID.

These checks identify the captured harness; they do not authenticate its origin. Webhook authentication and developer attribution follow the requirements above. A matching system message needs no user-agent label, and a matching user agent needs no particular system prompt. A session UUID alone or Aperture's own metadata.session_id is insufficient. The native request's session header is not part of the observed or documented webhook envelope, and this adapter does not use it.

Claude Code subagent requests, including its built-in Explore agent, are part of the conversation rather than background requests. They carry the parent conversation's native session ID, are recorded in its session and have their tool calls graded.

A Codex capture qualifies when any one of these Codex cues matches:

CueAccepted value
User agentmetadata.user_agent begins with codex (case-insensitive), followed by _, -, /, whitespace or the end of the value. This includes codex_cli_rs, codex_vscode, codex-tui, codex_exec and Codex Desktop/0.155.0-alpha.9; no particular version syntax is required.
Identity textA system or developer message, or Responses instructions, starts with You are Codex, .
Codex body metadatarequest_body.client_metadata has a non-empty string under x-codex-installation-id, x-codex-turn-metadata or x-codex-window-id. Codex writes these keys itself, so they also identify Codex's request body when it arrives under a different user agent, such as a generic Bun/ runtime value.

User-message text, model names, provider names and generic client_metadata keys such as thread_id, session_id or turn_id do not satisfy this gate. Modern Codex uses Responses; Chat Completions is accepted only when the captured request carries one of these cues. An OpenCode user agent keeps OpenCode's attribution in both formats even when the body also carries Codex metadata; a Qwen Code or Mistral Vibe user agent does so for Chat Completions only, because those markers do not admit Responses captures.

Codex's own helper requests, captured as Responses turns, are skipped as excluded_conversation even when a Codex cue matches. Codex runs each one under its own thread ID, so recording it would add a separate session with no agent work. Watcher reads thread_source in the JSON string at request_body.client_metadata["x-codex-turn-metadata"] and skips these values:

  • thread_title: naming the conversation.
  • system: the terminal UI's temporary structured threads, such as session recaps.
  • guardian_review: the auto-review approval reviewer.
  • guardian_classifier: the auto-review approval classifier.
  • thread_description: Codex Desktop writing a conversation's description. It forks the conversation for this request, so the request carries a copy of the conversation's history, including its tool calls. Those calls are the conversation's, not the description's.

A capture whose history or response contains a client tool call is recorded whatever its thread_source. For thread_description, only the helper's own calls count: one in the response, or one in the history after the request's last user message, which is the description instruction. Earlier history is the copy. Other values are recorded, including memory_consolidation, because Codex's memory agent runs tools. A missing or unreadable value is also recorded.

When a Codex conversation runs long, Codex compacts it: it sends one summarization request, then continues with a shortened history and a summary. With a custom model provider the summary is readable text. With its built-in OpenAI provider (including when pointed at Aperture) or Azure OpenAI, the summary is an encrypted item.

Watcher keeps monitoring a Codex conversation through either kind of compaction. It skips Codex's summarization request itself, replaces an encrypted summary with a placeholder (its content is never stored or shown), and inserts a compaction notice so graders know the agent's earlier context was compacted at that point. Later tool calls are graded as usual. When Watcher already holds earlier turns of the same Codex thread, the turns after the compaction continue that trajectory, so the earlier turns stay visible. In the Analyzer, the compaction appears as a "Context compacted" marker.

OpenCode captures require a metadata.user_agent beginning with the application name opencode, in lowercase, followed by /, whitespace or the end of the value. A version is optional and is not validated; both opencode and opencode/1.18.30 qualify. This engine marker also covers applications built on OpenCode. Custom system instructions are accepted. Title-generation and context-summary requests are retained even when they contain no client tool calls. OpenCode auxiliary requests are not separately classified or excluded. OpenCode does not inherit Claude Code or Codex session IDs. OpenCode 1.18.30 sends its session ID in the X-Session-Id header, which Aperture reports as an xsid_ session ID; Watcher uses that as the conversation ID, as described in Aperture session IDs. The title-generation request carries the same ID and is recorded as a separate trajectory in the conversation's session. The Analyzer opens a session at its newest trajectory by capture time, so a title request answered after the conversation's last request is shown first until the conversation continues.

A capture without that ID is placed by its transcript, and its auxiliary requests can create additional sessions. OpenCode's Gemini generateContent and streamGenerateContent captures then also use the Gemini history-matching fallback described below. It covers histories in which OpenCode replays earlier reasoning as explicit thought parts. OpenCode's other formats use ordinary transcript matching only. Without the ID, do not assume one Watcher session covers the complete native conversation.

The Qwen Code and Mistral Vibe user-agent markers admit Chat Completions captures when the value starts with QwenCode or Mistral-Vibe, with that exact capitalization, followed by a slash, whitespace or the end of the value. Version text is optional and is not validated. These markers alone do not admit Responses, Gemini, Anthropic or Bedrock Converse captures; independent recognized harness cues still apply to those formats. Captures use the generic source without a native conversation ID and reconcile by their transcript. A successful retry without a native ID can be graded again. Their auxiliary requests are not separately classified or excluded.

Resuming Qwen Code 0.23.4 or Mistral Vibe 2.25.4 can create a separate Watcher session when the client rewrites earlier conversation content. The resumed session retains the earlier tool results but does not link them to the judgments in the original session. Do not assume one Watcher session covers the complete native conversation.

OpenAI-compatible hosts, including OpenRouter, use the same Chat Completions and Responses formats and format-specific identity requirements above. API compatibility alone does not admit ordinary SDK applications or other coding agents. A configured route and a supported response shape are still required.

Codex places some tools in a Responses namespace: each MCP server's tools, and Codex's own tool groups such as collaboration and clock. Watcher folds the namespace into the tool name the way Codex's own hooks do, and uses that name in the transcript and in the call's judgment alike:

NamespaceRecorded nameExample
None, or Codex's default functionsThe bare tool nameexec_command
An MCP server, mcp__<server>mcp__<server>__<tool>mcp__github__create_issue
multi_agent_v1, tool spawn_agentThe bare tool namespawn_agent
Any otherThe namespace followed directly by the tool namecollaborationsend_message, clocksleep

Key command rules and tool thresholds for these tools to the recorded names. If Codex is configured to drop the mcp__ prefix from MCP namespaces, the capture no longer shows that the tool is from an MCP server, and Watcher names it by the last row, for example github and create_issue become githubcreate_issue, while Codex's hooks still report mcp__github__create_issue. Only the namespace is folded in: bare tool names are recorded as the agent sent them, even where Codex's hooks report a different name, such as Bash for exec_command.

For Chat Completions and Responses, recorded assistant model names preserve the response's model value, or the requested model when the response omits it. Watcher does not infer a provider prefix from the wire format, model name or route. Capture metadata separately records the provider key, reported model and URL without credentials, query parameters or fragments. These facts describe the captured exchange, not the original route of every historical message, and do not establish provider-specific pricing.

Watcher does not support Gemini CLI. A capture whose metadata.user_agent starts with a versioned GeminiCLI/ or GeminiCLI-tui/ marker with that exact capitalization, such as GeminiCLI-tui/0.45.2/gemini-2.5-pro (linux; x64; terminal), is still recorded as Gemini CLI and its tool calls are reviewed, but nothing else is adapted to it. Its helper requests, such as chat compression, web search and fetch, and model routing, are recorded like agent turns, usually each as a session of its own. Its captures reconcile only by exact transcript matching, so when the client replays earlier turns differently, the conversation continues in a new session. Gemini CLI conversation IDs are not available in these captures, and Watcher ignores Aperture's gcli_ session ID.

OpenCode's Gemini captures without a conversation ID use a history-matching fallback. It tolerates assistant whitespace and text/thought block changes, and text disappearing beside an unchanged tool call when the continuation contains its result. User messages, tool arguments, results, models and media must still agree. The incoming history must be longer than the stored history it continues. It must also share a completed tool invocation with that history, and exactly one session may match. The fallback preserves the different representations as separate trajectories within that session, with tool judgments available by their stable call IDs. The same comparison, reversed, recognizes a capture recorded after a longer history that already contains it; that capture adds no messages. An exact continuation from an older retained representation must also agree with the longest stored histories. A conflicting follow-up is not added to that Watcher session. Ambiguous matches, text-only conversations, and previously recorded captures without this matching capability stay on ordinary transcript matching. The fallback cannot resolve persistent ambiguity: later rewritten captures can continue forming separate sessions. Switching models can also split rewritten histories. The search considers only the same user's sessions whose messages before the first assistant reply, such as the system instructions and opening request, are identical to the incoming capture's. More than 32 such sessions, or more than 10,000 stored messages in them, stops the search, even when only one session would match, and the capture then reconciles by its transcript instead. Without a native conversation ID, grouping captures into one Watcher session does not prove that they belong to the same native conversation.

An Antigravity CLI capture qualifies when either of these cues matches:

CueAccepted value
5d. CLI user agentmetadata.user_agent begins with antigravity/cli (lowercase), followed by /, whitespace or the end of the value. The CLI sends it in Application Default Credentials (ADC) mode, set with AGY_ADC_AUTH=true. Example: antigravity/cli/1.2.6 (aidev_client; os_type=linux; arch=amd64; cl=983534196; auth_method=adc).
5e. Identity textA system text part begins with <identity>, optional whitespace and You are Antigravity. The name must end at a comma, period, whitespace, </identity> or end of text.

For example, this system text satisfies 5e:

<identity>
You are Antigravity, a coding assistant.
</identity>

Version syntax is not checked. Either cue is accepted with either the Gemini API or the Vertex format. In Gemini API-key mode the CLI sends the generic Google Go SDK user agent, for example google-genai-sdk/1.71.0 gl-go/go1.28-20260721-RC03 cl/951519500 +3ebc191975 X:fieldtrack,boringcrypto,simd,mapsplitgroup. Unrelated Go applications send the same user agent, so it never admits a capture on its own; these captures qualify through 5e.

Cue 5e applies under any user agent except the Gemini CLI and OpenCode markers, which keep their own attribution. These cues identify the harness; they do not authenticate the client. Watcher does not distinguish Antigravity CLI from another client that sends either cue: such captures are recorded as Antigravity CLI.

Antigravity's title-generation requests are excluded. Watcher recognizes one by either of two signals: a system text part beginning with You are a conversation title generator, or a request that declares no tools and has no Antigravity identity text. Otherwise, a request without tools that carries the identity text is still recorded. Neither signal excludes a request that declares client functions or whose response proposes a client tool call: the CLI may run that call, so the capture is recorded and the call is graded.

Antigravity's native conversation UUID is read from the Conversation ID: line in its <user_information> system block and stored in the session's external_ids under the other key; the session is still recorded as Antigravity CLI. The block must occur once across the system text and belong to the single text part that starts with the Antigravity identity. A missing, invalid or ambiguous UUID, including a capture admitted only by 5d, leaves identity to ordinary transcript reconciliation. User messages cannot supply this UUID. The Gemini history-matching fallback does not apply to Antigravity.

These rules apply to every Gemini capture Watcher records:

  • Watcher assigns tool-call IDs from user/tool history and invocation order, and uses wire IDs only to match results, so calls and results remain linked when a client adds wire IDs in a later request. Changes to user/tool history, including compression, can change the generated IDs. Retries without a native conversation ID may be graded again.
  • Only the first response candidate is recorded. Provider-executed tools are context and are not graded.
  • Identical results repeated with the same explicit ID are recorded once; conflicting repeats are rejected. Orphaned tool results are rejected.
  • Request-history blocks with role model containing only function responses are recorded as tool results, and must match preceding calls. Mixed assistant/results blocks and function responses in the new response candidate remain invalid.
  • A completed response whose candidate has no parts (finishReason STOP with null or absent parts, or an empty parts list) records the request's history, including its latest tool results, without an empty assistant message. Other responses that omit candidate content, such as token-limited or safety-blocked ones, are skipped as malformed_capture, or as unsupported_api when the entire candidate list is absent.

Antigravity CLI's Gemini API-key mode sends the Gemini API format. Its gateway route must permit that format for the selected model. Selecting a Gemini model does not make the request a Vertex API request.

Antigravity CLI's ADC mode sends Vertex streamGenerateContent requests to aiplatform.googleapis.com. CLI 1.2.6 does not apply GOOGLE_VERTEX_BASE_URL in this mode, and Antigravity documents no other setting that changes this host. Without such a setting, these requests reach Aperture only if HTTPS traffic for that host is redirected there, through an endpoint presenting a certificate for that host that the CLI trusts. The gateway route must also permit the Vertex format for the selected model. Supported agents does not assess this route.

Responses function calls, custom tool calls and local shell calls are recorded with their call IDs. So is a client-executed tool search (tool_search_call with execution: "client"), which Codex sends to find deferred MCP, app or dynamic tools: it is recorded and graded as a tool_search call, and the matching tool_search_output is recorded as its result. Supported provider-executed actions remain transcript context and are not selected for client-tool grading. Provider-action IDs are generated from the action order visible in the capture. They can differ from the provider's item IDs. Requests using previous_response_id or conversation to reference provider-managed history are skipped: Watcher cannot recover the missing earlier context. Opaque compaction items are recorded as the placeholder described for Codex above. Malformed historical tool arguments remain raw transcript context, so a later valid call can still be graded. Malformed arguments or duplicate IDs in newly returned client calls cause the capture to be skipped. New client calls must also have different IDs from the earlier client calls in the supplied history. Chat Completions uses tool_calls; the legacy function_call field causes a skip even when it appears only in supplied history. Unrecognized content inside a Responses assistant message remains JSON text in the transcript. Input parts mixed into an output message are also retained as JSON text. This applies to new output and supplied history; recognized text and refusal fields must still contain strings. Chat audio output and its transcript are not recorded. For Chat Completions, only the first response choice and its client tool calls are recorded.

Provider aliases and hostnames do not grant admission. Watcher identifies the API format using both bodies and the endpoint path in metadata.url. A unique paired body signature wins even when the URL suggests another format. Otherwise, a known URL selects its format when no signatures match or when its format is among several matches. Unresolved ambiguity is skipped. Only the selected parser runs, and its validation must still succeed. Hosted Anthropic APIs use the same Claude Code or OpenCode identity checks above and require a reconstructed Messages response:

Host and APICapture support
5h. Amazon Bedrock Anthropic Invoke (invoke, invoke-with-response-stream)Claude Code tool calls, tool results, and resumed conversations are supported.
5i. Google Vertex Anthropic (rawPredict, streamRawPredict)Claude Code tool calls, tool results, and resumed conversations are supported.
5j. Microsoft Foundry Anthropic (/anthropic/v1/messages)Uses Anthropic Messages and the Claude identity requirements above.

Hosted Anthropic captures keep the anthropic/ model prefix and the response's model name. Watcher does not rename Vertex or Foundry models to vertex/ or azure/, or normalize Bedrock model names for pricing.

Bedrock Converse and ConverseStream accept OpenCode captures with a reconstructed output.message response. Tool-use IDs link results to their earlier calls; only the new response's client calls are selected for grading. Signed reasoning and inline images are retained. Documents, remote media and unfamiliar blocks remain serialized context and are not fetched. Prompt-cache markers are omitted from recorded messages. Duplicate call IDs and unmatched results, and blocks with competing content types, are skipped as malformed captures. Model attribution uses bedrock/ plus the model identifier from the endpoint, or bedrock/unknown on a custom path. OpenAI models hosted on Bedrock remain unverified.

Application names and provider ownership do not grant admission. Claude Desktop and Cowork traffic must carry the Claude Code markers above; Codex Desktop traffic must carry a Codex marker. These engine markers do not identify the selected desktop mode. A provider's public API support does not establish support for a consumer app's protocol or conversation history, and a model appearing in an application's menu does not establish Aperture routing. Confirm the application's requests appear in Aperture using a supported format.

See Supported agents for the assessed agent and provider pairings.

Watcher uses the configured response body to extract new client tool calls for observation; it does not require Aperture's optional tools send type. Historical and provider-executed calls remain context. The generic HTTP endpoint's 32-input limit does not apply to captured Aperture responses. The shared executor bounds fan-out using the monitor-grade admission limit.

Unrecognized application traffic and known Claude Code background requests such as warmups, usage-limit checks, title generation, and Auto Mode permission classifications are skipped. The classifier is recognized by its system text or by a request with no tools whose stop_sequences list contains </severity>. The usage-limit check is a single user message quota with max_tokens set to 1. Either classifier cue is sufficient, and background requests are skipped even when they carry the conversation's native session ID. A background request is recognized by its leading system prompt, its request shape or its final user messages, not by text in later mid-conversation system messages. A Claude Code capture whose response proposes a client tool call is never skipped as a background request: it is recorded and its calls are graded. Null or upstream-error responses and captured content rejected by validation are skipped too. Skipped deliveries are acknowledged with HTTP 202 and logged, but do not create sessions or grades. The aperture_hook_skipped log event records one of these reasons:

ReasonMeaning
6a. missing_responseThe captured response is null or absent, as when the client disconnects before the provider responds. Also: Aperture replaced the response with an aperture_error marker, for example after a response truncated upstream; the response has null content (a refusal with no content blocks); it reports an Anthropic error or OpenAI/Gemini/Converse error object; or it has a failed/cancelled Responses status.
6b. unsupported_apiThe API format is unsupported or ambiguous, or the required request/response fields for that format are missing.
6c. malformed_captureThe response carries an invalid_body marker, message content fails validation, metadata.url cannot be parsed, a Responses computer_call or provider-run tool_search_call (any execution other than client) is unsupported, or a history reference leaves earlier context unavailable.
6d. excluded_conversationThe capture does not match a supported coding-agent identity, or matches an excluded background request (Claude Code, or a Codex helper request identified by its thread_source).
6e. ownership_conflictThe capture's Claude Code or Codex conversation is already recorded in a session that belongs to a different developer, for example by a Watcher client signed in as someone else. The capture is not graded or recorded.

Claude Code background requests recognized by their system prompt, such as the web search helper, are excluded before their response is parsed when that response contains no client tool call. They produce excluded_conversation even when the response would fail validation.

A Converse response with only a top-level message and no reconstructed output is classified as unsupported_api, including errors delivered in that shape.

Once an Aperture envelope passes validation, every log line for that delivery, from the capacity check through grading and recording, identifies the capture without its content:

FieldMeaning
aperture_request_id, aperture_session_idAperture's metadata.request_id and metadata.session_id, exactly as Aperture sent them, so you can search the log for an identifier Aperture shows and group the lines of one capture or one Aperture session. aperture_session_id is null when Aperture sent no session identifier. Neither is used as a conversation ID.
aperture_user_agent_familyThe captured client: claude-cli, codex, opencode, gemini-cli, antigravity, qwen-code, mistral-vibe, other or absent. The family names the application marker the user agent matches, not whether the capture was admitted. antigravity is the Antigravity CLI's own user agent, which it sends in Application Default Credentials mode; in Gemini API-key mode it sends the generic Google Go SDK user agent, which is logged as other. The user agent itself is never logged.

A malformed_capture skip also records error_class and error_module (the exception class), error_cause (the class of the exception it was raised from) and, when one of Watcher's format parsers raised it, error_code, a fixed phrase such as invalid_tool_arguments. A skip caused by Aperture's invalid_body marker records error_code: invalid_body only. None of these fields contains message text, tool arguments or other values from the capture. Unexpected processing failures are logged separately as aperture_hook_failed with the exception class.

  1. Configure an authenticated, reachable Watcher endpoint as described above.
  2. Choose the Aperture users or device tags and provider keys whose conversations you want to observe. Start with a test scope. Avoid capturing the same test conversation through both Aperture and an installed Watcher client.
  3. Merge the following hook and grant into your existing Aperture configuration. Replace the URL, API key, login, and provider pattern with your values. The example is for an SSO-mode deployment.
{
"hooks": {
"watcher": {
"url": "https://watcher.example.com/api/v1/hooks/aperture",
"authorization": "x-api-key",
"apikey": "<organization-api-key-with-sessions:write:any>",
"timeout": "60s"
}
},
"grants": [
{
"src": ["developer@example.com"],
"app": {
"tailscale.com/cap/aperture": [
{
"models": "anthropic/**",
"send_hooks": [
{
"name": "watcher",
"events": ["entire_request"],
"send": ["request_body", "response_body"]
}
]
}
]
}
}
]
}

The timeout value is a duration string. We recommend 60s. See Aperture delivery timeout.

The models pattern uses your Aperture provider key. For a provider named bedrock, use bedrock/**; add a separate capability entry to capture both providers. A src device tag must already identify the intended machine. A login scope captures that user's matching requests from all their machines. See Aperture's configuration reference for grant syntax.

Aperture sends a fixed JSON envelope containing metadata, request_body, and response_body. metadata.login_name supplies developer attribution. When the Watcher client already recorded the conversation, the capture joins that session, and a session that belongs to no one becomes the developer's. When the session belongs to a different developer, the capture is skipped (ownership_conflict). Watcher reads Claude Code's native conversation ID from request_body.metadata.user_id, Codex's from request_body.client_metadata.thread_id, and Antigravity CLI's from the <user_information> block in its identity system text. If no native ID is available, Watcher reconciles transcript prefixes. Codex's thread ID identifies one conversation; its body session_id can be shared by a parent and its child agents. A missing or invalid thread ID leaves the native ID unset, even if a valid session_id is present. Watcher never uses Aperture's request_id as a conversation ID, and uses Aperture's metadata.session_id only as described in Aperture session IDs.

Watcher turns client tool calls in the response into one generic batch, using prior turns only as context and ingesting the transcript once after evaluation finishes. Completed judgments can be reused on redelivery when the request has a native conversation ID or a kept Aperture session ID and the key has permission to read the session. Captures without either ID and keys with only sessions:write:any grade again. Failed or unjudged records are evaluated again. Read permission grants broader session access; it is optional and is not needed to accept or record a delivery. Text-only turns are ingested too. An advisory denial records what Watcher judged; this asynchronous hook cannot stop the tool from running.

Aperture session IDs​

Aperture adds its own metadata.session_id to each delivery and prefixes it with how it derived the value. Watcher keeps two prefixes and ignores the rest. A kept value is used only for a capture without a native conversation ID; a native ID stays the capture's only ID.

PrefixAperture derives it fromWatcher
7a. xsid_The client's X-Session-Id request header, from whichever client sends it. OpenCode sends its session ID there, and keeps it when a session is resumed.Uses it as the conversation ID
7b. pisn_A pi agent session. Whether it stays the same when a session is resumed has not been verified.Uses it as the conversation ID
7c. ancc_, bdrk_A Claude Code session on Anthropic or Bedrock.Ignores it: Watcher reads Claude Code's own session ID from the request
7d. ocdx_Codex's Session-Id header, which a parent agent shares with its subagents.Ignores it: it names several conversations, and Watcher reads each Codex thread ID instead
7e. gcli_Gemini CLI.Ignores it
7f. orfp_, anfp_, xrnd_Fingerprints of request content, or a random value, that change on nearly every request.Ignores it
7g. Any other prefixNot documented.Ignores it

A kept value is stored in the session's external IDs under the tailscale-aperture key and places captures the way a native ID does:

  • Captures with the same ID join one session even when the transcript no longer matches: a resumed session with a changed system prompt, a reverted turn, a compacted history, or an auxiliary request, which becomes a separate trajectory in that session.
  • A capture whose transcript continues a session that has a different ID starts its own session with a copy of the shared history. A forked OpenCode session therefore gets its own session, and the parent's next turn stays in the parent's.
  • Sessions with different IDs never merge, even when their first captures are identical.

A client that sends X-Session-Id through Aperture should keep the value constant for exactly one conversation. Reusing it for several conversations merges them into one session. Sending a new value with each request makes every capture look like a fork, so each gets its own session. Captures without a kept value are placed by their transcript, as described above for each harness.

The stored value is returned in the session's external_ids by the sessions API and the Python SDK, whose search_sessions filter finds the sessions you can read that carry one value, for example external_ids={"tailscale-aperture": ["xsid_…"]}. The Analyzer does not show it.

Watcher records a capture only after all its tool calls are graded, so a capture whose calls take longer to grade can be recorded after a later capture of the same conversation. A late capture with a kept value joins the session holding it, adding a branch where its transcript differs from the stored one. Without one, when exactly one session already stores a longer transcript containing the late capture's conversation, Watcher records the late capture's decisions in that session, linked to the stored tool calls and results, and stores none of its messages again. OpenCode Gemini captures recognize containment with the history-matching comparison described above; other captures, Gemini CLI's included, require the stored transcript to contain the capture exactly. When several sessions contain it, an exact copy's judgments are not recorded, and the delivery logs aperture_hook_recording_incomplete; an OpenCode Gemini capture starts a new session instead.

Delivery limits and verification​

ResultEndpointMeaning
HTTP 200 with a decision or ordered listGenericEvaluation finished. Observe returns a completed advisory judgment. Recording can still be in progress.
HTTP 200 with []GenericTranscript-only input was accepted; no tool calls were evaluated.
HTTP 200 with decision_source: "grading_error"GenericA grading timeout, grade-capacity limit, exhausted allowance, or grading failure invoked policy_monitors.on_grading_failure. Inspect grading_error.code and apply the returned decision.
HTTP 202, {"status":"accepted"}ApertureAdmitted for background processing. It does not confirm completed grading or durable storage.
HTTP 202 followed by a logged skipApertureThe capture has a missing/error response, an unsupported API shape, malformed content, or an excluded conversation. No session or grade is created.
HTTP 403ApertureThe caller lacks sessions:write:any, needed for developer attribution.
HTTP 422ApertureThe outer webhook envelope is invalid. Watcher logs aperture_hook_rejected with each error's field location and type, never the rejected values. Unsupported captured API formats are skipped after HTTP 202 instead.
HTTP 503ApertureDelivery workers are full. This delivery was not accepted.
HTTP 401BothAuthentication is missing or invalid.
HTTP 413BothThe request exceeds the configured hook body limit. Watcher logs hook_request_body_too_large with the limit and the declared or received size.
HTTP 422GenericRequest validation failed or settings_overrides is invalid or forbidden. The whole request is rejected before ingestion or grading. The detail field is a list of validation errors.
HTTP error for a scalar; status_code and detail in one batch outputGenericA monitor stage refused the call, for example HTTP 500 when its configured monitor is unavailable on the deployment. A batch keeps that error in input order and continues with the other inputs.

The default request-body limit is 8 MiB on both hook endpoints. Self-hosted operators can set WATCHER_HOOKS_MAX_REQUEST_BYTES in the Compose .env to change it. Helm deployments use api.hooksMaxRequestBytes. Both endpoints share a limit of 128 in-flight monitor grades per server process. This limit does not reserve capacity for enforcement or provide per-organization or per-session quotas. Paused requests and requests with only unnamed or no inputs need no grading admission. Their ingestion still needs database capacity and permission to record the transcript.

The Aperture endpoint admits up to 64 deliveries per server process through evaluation and recording, independently of calls per delivery. Generic observe requests wait for completed judgments and do not consume this delivery limit. Self-hosted operators can set WATCHER_HOOKS_MAX_INFLIGHT_DELIVERIES in the Compose .env. Helm deployments use api.hooksMaxInflightDeliveries. Increasing admission raises memory demand: 64 deliveries at the default 8 MiB body limit can retain 512 MiB of raw payloads before parsing and grading overhead.

Hook execution does not retain database transactions while waiting for monitor responses. Timed-out grades continue under the same admission bound so their results and usage can be retained.

Aperture background work and generic post-response recording are held in memory, not a durable queue. A server restart can lose an accepted Aperture delivery or interrupt recording. Retrying can incur additional grading charges when a reusable evaluation is unavailable, recording has not finished, or force_regrade is true. Decision recording keeps one canonical decision. Missing-grade recovery can link evidence from another attempt, and some grades can remain unlinked. A native enforced decision takes precedence over a hook observation unless force_regrade explicitly requests replacement. The caller owns transport-failure handling.

To verify the integration, send the example request above with a unique external_ids.other value. Inspect the returned decision and find the session in Analyzer under the authenticated or authorized attributed user. In observe mode, verify that the response is deferred, retains the completed judged_decision, and that the later stored judgment is advisory. With an administrator-paused hook policy, verify that the transcript appears without hook decisions or hook grades. Check the server logs if the expected session does not appear after a successful response.

Aperture's asynchronous hooks fail open; this integration does not promise retries or delivery of every turn. Unsupported formats, excluded background calls, and processing failures are recorded in server logs after admission.

For Aperture, run a scoped conversation in the coding agent you are integrating and have it use a tool. Find its session in Analyzer under the developer's login and inspect the tool-call judgment and attached grades. Verify that the record is advisory and that the tool still ran. With an administrator-paused policy, verify that the transcript appears without hook decisions or grades.

Aperture delivery timeout​

Aperture's hook timeout bounds the whole delivery. It can expire while the delivery waits to be sent behind other deliveries, not only while Watcher responds. A large capture that uploads slowly can hold later deliveries for tens of seconds. With Aperture's 5-second default, those later deliveries can time out.

Aperture abandons a delivery that times out. Its documentation describes no retry for asynchronous hooks. Usually Aperture has already started the request, so it cancels the request before the body finishes arriving. Watcher then never receives the complete capture, so it writes no session update or grade for that turn. If the timeout expires after Watcher admits the delivery but before Aperture sees the HTTP 202, processing still continues.

The entire_request event does not delay the coding agent, so a long timeout costs no agent latency. Use at least 60s, as in the example above.