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.
| Endpoint | When to use it | Who can enforce the result |
|---|---|---|
POST /api/v1/hooks/pre-tool-use | Send 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/aperture | Aperture 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 mode | Generic pre-tool-use hook | Aperture hook |
|---|---|---|
enforce | Evaluates the call. Returns an enforceable decision. | Evaluates the call. Records an advisory judgment. |
observe | Evaluates 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. |
paused | Ingests 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.
| Agent | Tools translated, as agent name → Claude Code name | Left under the agent's own name |
|---|---|---|
| Claude Code, including Claude Cowork | mcp__workspace__bash → Bash. Claude Code's own tools already use these names. | Everything else. |
| Codex | exec_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 → ToolSearch | exec (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. |
| OpenCode | bash → 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 → ExitPlanMode | list, and the experimental code-mode tool execute. apply_patch already matches. |
| Gemini CLI | run_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, ExitPlanMode | list_directory, read_many_files, save_memory, get_internal_docs. |
| Qwen Code | run_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, ExitPlanMode | exec (code mode), list_directory, save_memory, and the scheduling, team and worktree tools. |
| Mistral Vibe | bash, 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 → ExitPlanMode | The companion tools that read or feed a running shell session. |
| Antigravity CLI | run_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 → WebSearch | list_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
Bashcommand text, whatever key the agent uses. A command sent as an argument list is joined into one shell command. A list of the formbash -c <script>contributes only the script, and only when the program isbash,shorzsh, by name or under/binor/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
srcor./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 ascat passwdrun in/etc, matching a read-only allow rule. Codexexec_commandand 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, Codexlocal_shellenv), a shell executable override (Codexexec_commandand Mistral Vibeshell), another execution environment (Codexexec_commandenvironment_id), or a user to run as (Codexlocal_shelluser). 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,WriteorEditpath.
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_thresholdsentry whose key is exactly the agent's tool name, such asbash, 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_namematches 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 aBashallow 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 toReaditself, for translated and Claude Code reads alike, but not rules matching the agent's name. So under the built-in thresholds a.envdeny keyed toReadnever fires, while one keyed toread_filefires for Gemini CLI reads. An always-allow entry keyed toread_filewould switch that rule off too. - A rule whose
tool_namealso 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
| Field | Contract |
|---|---|
format | Required; use watcher. |
messages | The 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 []. |
input | One 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_input | Each input object requires tool_name; tool_input supplies its arguments. Omission is allowed for a matching call with empty {} arguments. |
input.tool_use_id | A 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_ids | Conversation 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. |
user | Optional developer email for callers authorized to write sessions for other users. Otherwise ignored. |
force_regrade | false by default. Set to true to evaluate again instead of reusing a completed judgment. Does not enable grading in paused mode. |
mode | full by default. triage_only skips the gateway monitor after triage; a call triage does not clear returns an escalation judgment. |
ephemeral | false 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_overrides | Optional 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_version | For 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:
decision | Caller behavior |
|---|---|
allow | The call may proceed. |
deny | Do not execute the call. |
escalate | Obtain human approval before proceeding, or apply your integration's policy for unavailable approval. |
deferred | Watcher 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:
| Format | Admitted coding-agent identity |
|---|---|
| Anthropic Messages, including the hosted variants detailed below | Claude Code or OpenCode, using the markers below. |
| OpenAI Responses, including OpenRouter | Codex or OpenCode, using the markers below. |
| OpenAI Chat Completions, including OpenRouter | Codex, OpenCode, Qwen Code or Mistral Vibe, using the markers below. Ordinary OpenAI SDK application traffic is excluded. |
Gemini generateContent, including Vertex | Antigravity CLI or OpenCode, using the markers below. Gemini CLI captures are recorded without support, as described below. |
| Bedrock Converse and ConverseStream | OpenCode'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:
| Cue | Accepted value |
|---|---|
| 5a. User agent | The webhook envelope's metadata.user_agent starts with claude-cli/. |
| 5b. CLI system text | Contains You are Claude Code, Anthropic's official CLI for Claude. |
| 5c. Agent SDK system text | Contains 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 metadata | request_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:
| Cue | Accepted value |
|---|---|
| User agent | metadata.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 text | A system or developer message, or Responses instructions, starts with You are Codex, . |
| Codex body metadata | request_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:
| Namespace | Recorded name | Example |
|---|---|---|
None, or Codex's default functions | The bare tool name | exec_command |
An MCP server, mcp__<server> | mcp__<server>__<tool> | mcp__github__create_issue |
multi_agent_v1, tool spawn_agent | The bare tool name | spawn_agent |
| Any other | The namespace followed directly by the tool name | collaborationsend_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:
| Cue | Accepted value |
|---|---|
| 5d. CLI user agent | metadata.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 text | A 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
modelcontaining 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 (
finishReasonSTOPwith null or absentparts, or an emptypartslist) 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 asmalformed_capture, or asunsupported_apiwhen 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 API | Capture 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:
| Reason | Meaning |
|---|---|
6a. missing_response | The 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_api | The API format is unsupported or ambiguous, or the required request/response fields for that format are missing. |
6c. malformed_capture | The 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_conversation | The 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_conflict | The 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:
| Field | Meaning |
|---|---|
aperture_request_id, aperture_session_id | Aperture'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_family | The 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.
- Configure an authenticated, reachable Watcher endpoint as described above.
- 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.
- 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.
| Prefix | Aperture derives it from | Watcher |
|---|---|---|
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 prefix | Not 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
| Result | Endpoint | Meaning |
|---|---|---|
| HTTP 200 with a decision or ordered list | Generic | Evaluation finished. Observe returns a completed advisory judgment. Recording can still be in progress. |
HTTP 200 with [] | Generic | Transcript-only input was accepted; no tool calls were evaluated. |
HTTP 200 with decision_source: "grading_error" | Generic | A 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"} | Aperture | Admitted for background processing. It does not confirm completed grading or durable storage. |
| HTTP 202 followed by a logged skip | Aperture | The capture has a missing/error response, an unsupported API shape, malformed content, or an excluded conversation. No session or grade is created. |
| HTTP 403 | Aperture | The caller lacks sessions:write:any, needed for developer attribution. |
| HTTP 422 | Aperture | The 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 503 | Aperture | Delivery workers are full. This delivery was not accepted. |
| HTTP 401 | Both | Authentication is missing or invalid. |
| HTTP 413 | Both | The request exceeds the configured hook body limit. Watcher logs hook_request_body_too_large with the limit and the declared or received size. |
| HTTP 422 | Generic | Request 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 output | Generic | A 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.