---
description: >-
  Connect gateways to Watcher for enforcement, observation, or transcript ingestion, including Aperture authentication and delivery limits.
---

> Part of the Watcher docs. Index of all pages, with summaries: https://watcher-docs.apolloresearch.ai/llms.txt
> Before upgrading Watcher, read the release pages and full changelogs for every version crossed: https://watcher-docs.apolloresearch.ai/releases/index.md
> Found an issue with this page? Report it: https://github.com/ApolloResearch/watcher-bin/issues/new?title=Docs%20feedback%3A%20Gateway%20hooks&body=Page%3A%20Gateway%20hooks%0AURL%3A%20https%3A%2F%2Fwatcher-docs.apolloresearch.ai%2Fapi%2Fhooks%2F%0A%0A

# Gateway hooks

[Gateway hooks](../concepts/index.md) let a gateway or agent harness send
conversations to the [Watcher API](index.md). You do not need to install the
[Watcher client](../client/index.md) on developer machines. The resulting
[sessions](../concepts/sessions-and-trajectories.md), tool-call judgments, and
grades appear in the [Analyzer](../concepts/analyzer.md). See the
[gateway deployment reference](../self-hosting/gateway-deployments.md) for
Aperture and LiteLLM diagrams, network paths, and credential boundaries.

| Endpoint | When to use it | Who can enforce the result |
| --- | --- | --- |
| `POST /api/v1/hooks/pre-tool-use` | Send a structured conversation, or a raw provider request and response, 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](../concepts/sessions-and-trajectories.md#trajectories). 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](index.md#authentication) and
[self-hosted authentication](../self-hosting/setup.md).

## Enforcement modes

The [enforcement mode](../concepts/index.md) in the administrator's
[managed client settings](../concepts/organization-settings.md#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:

```yaml
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:

```json
{"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](../concepts/blocking-and-trailing-review.md#enforcement-modes).
The client still evaluates deterministic checks. Hook modes do not configure
[trailing review](../concepts/blocking-and-trailing-review.md#trailing-review).
Trailing review can separately review ingested sessions.

## Tool names and policy

[Tool thresholds](../concepts/blocking-and-trailing-review.md#blocking-review)
and [command rules](../concepts/blocking-and-trailing-review.md#blocking-review)
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](#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.

Policy does not support Codex's `non_prefixed_mcp_tool_names` feature, which is
off by default, for captured provider requests: the Aperture hook and the
generic hook's [raw format](#raw-provider-request-and-response). With it on,
Codex's requests no longer mark a tool as coming from an MCP server. Watcher
then names the tool by running the server name and the tool name together, as
the last row of the Codex namespace table under
[Configure Aperture](#configure-aperture) describes. That name can
spell a Codex tool the table above translates. A server named `local_` with a
tool `shell` gives `local_shell`, and a server named `collaboration` with a tool
`send_message` gives `collaborationsend_message`. Such an MCP call is evaluated
as `Bash` or `SendMessage`, under those tools' thresholds and rules, allow rules
included, so it can be allowed without grading. Entries keyed to
`mcp__<server>__<tool>` do not apply to it. Keep the feature off for Codex
traffic that Watcher receives as captured provider requests. A generic hook
request in the ordinary format is evaluated under the tool names it sends, so
send MCP tools as `mcp__<server>__<tool>` there. The Watcher client is not
affected, because Codex's own hooks still report these tools as
`mcp__<server>__<tool>`.

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 gateway 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:

```json
{
  "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:

```bash
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`:

```json
{
  "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:

```json
{
  "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 stored as other. Session responses may report a [recognized SDK agent](python-sdk.md#filter-by-coding-agent) from its retained adapter ID. 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](#aperture-session-ids). |
| `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](#tool-names-and-policy): 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:

```json
{"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.

### Validation errors

Request validation failures return HTTP 422 and reject the whole request before
ingestion or grading. The `detail` list contains error locations (`loc`), messages
(`msg`) and categories (`type`). Body validation errors can include the selected format,
`watcher` or `raw`, immediately after `body` in `loc`.

For example, a request with `format: "watcher"` and `mode: "invalid"` returns
an error like this:

```json
{
  "detail": [
    {
      "loc": ["body", "watcher", "mode"],
      "msg": "Input should be 'full' or 'triage_only'",
      "type": "literal_error"
    }
  ]
}
```

Do not require the format segment on every error. A missing or unknown `format`
can produce `loc: ["body"]`. Errors raised while converting a raw exchange also
use `["body"]`. A locked settings override uses `["body", "settings_overrides"]`:

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

Handle validation failures by HTTP status and error `type`, rather than exact
message text or a fixed location length. If your client matches field locations,
accept or remove the optional `watcher` or `raw` segment immediately after
`body` before applying those matches. Keep the rest of the location unchanged,
including array indices. Accept locations without that segment too.

The [Python SDK](python-sdk.md#error-handling) raises `WatcherValidationError`
for HTTP 422. Code that only handles that exception needs no change for these
location segments. Custom code that inspects the exception's `body` must handle
them as described above.

### Raw provider request and response

A synchronous hook, such as a LiteLLM proxy callback, can send the provider
request and response as they are with `format: "raw"`. Watcher converts them
and selects the new tool calls. The SDK's `pre_tool_use_hook` accepts
`request_body` and `response_body` for this, and the SDK's LiteLLM example
uses it.

Supported formats are OpenAI Chat Completions, OpenAI Responses, Anthropic
Messages, Gemini `generateContent` and Bedrock Converse. Send the request body
exactly as the client sent it and the complete response in the same format.
Assemble a streamed response into its non-streaming form first.

```json
{
  "format": "raw",
  "request_body": {"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "Tidy the notes file."}], "tools": ["..."]},
  "response_body": {"id": "chatcmpl-1", "model": "gpt-5.4-mini", "choices": ["..."]},
  "url": "/v1/chat/completions",
  "user_agent": "my-agent/1.0",
  "external_ids": {"other": "conversation-123"}
}
```

| Field | Meaning |
| --- | --- |
| `request_body`, `response_body` | Required. They replace `messages` and `input`. |
| `url` | The client-facing endpoint path or URL. It resolves formats with similar bodies and is recorded without query or credentials. |
| `user_agent` | The client's `User-Agent`. With the request body, it identifies supported coding agents. |
| `provider` | Your route or model alias, recorded with the messages. |
| `external_ids` | Your conversation ID, usually under `other`. Supported coding agents add their own session IDs; you cannot replace one they supply. Omit it to group requests by shared history. |
| `response_index` | Zero-based array position of the Chat choice or Gemini candidate to evaluate, regardless of its provider `index` field. Required when there are multiple alternatives. |
| `user`, `mode`, `ephemeral`, `force_regrade`, `skip_rules`, `policy_version`, `settings_overrides` | As in [request fields](#request-fields). |

The response is a list with one output per selected new client tool call,
in response order, shaped like the outputs in
[handle the response](#handle-the-response). Each output's `tool_use_id` is the
call's ID in your `response_body`. Gemini calls get generated IDs and are
matched by order within the selected candidate.

Some client calls in the original response have no output. For example,
Anthropic calls before the final `fallback` block are omitted. Calls in
unselected choices or candidates are also omitted. An empty list means no
client calls were selected, not that the original response contains none.
Provider-run calls and calls from earlier turns are context only.

Before releasing executable content:

1. Identify every client tool call in the response you will deliver. Deliver
   only the selected choice or candidate when the provider returns alternatives.
2. Match each call to exactly one output using its provider call ID. For
   Gemini, match by call order and require equal call and output counts.
3. Withhold the response if a call has no output, IDs are duplicated, or an
   output cannot be matched to a call. Otherwise, apply each
   [decision](#handle-the-response). An error output is not permission to run
   the call.

One response may contain any number of tool calls; the 32-input limit applies
only to the `watcher` format. Supported coding agents' own helper requests
without selected client calls return `[]` and are not recorded: Claude Code
background requests such as its permission classifier, Codex thread titles,
approval reviews and memory extraction, and Antigravity title requests. The
same delivery checks apply to these responses.

A pair Watcher cannot convert returns HTTP 422 with one of these messages:
`provider_error` for a provider error body, `unsupported_api` when no supported
format matches, and `malformed_exchange` for invalid content or an unselected
alternative. Other invalid fields and locked settings overrides return the usual
validation errors.

## Configure Aperture

For support levels by agent and provider, see
[Supported agents](../supported-agents.md#through-aperture).

Prerequisite: your coding agent's requests already appear in Aperture's logs.
Use [Aperture's client setup guides](https://tailscale.com/docs/aperture/use-your-tools)
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](https://tailscale.com/docs/aperture/how-to/use-codex)
does, and set `supports_websockets = false` in it:

```toml
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](#authentication-and-reachability). 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. Watcher reads the JSON
string at `request_body.client_metadata["x-codex-turn-metadata"]`. Most helpers
run under their own thread ID, so recording one would add a separate session
with no agent work. Watcher skips requests whose `thread_source` is one of
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. Codex reuses a reviewer
  thread across approvals, and Codex Desktop can start a new reviewer thread
  with a copy of the previous reviewer's history, so a review request can carry
  tool calls from an earlier review.
- `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.

Watcher also skips requests whose `request_kind` is `memory`. When Codex's
memories feature is on, a starting conversation first sends one such request,
asking the model to extract memories from an earlier conversation's
transcript. It carries the new conversation's thread ID, so recording it would
add the earlier conversation's content to the new conversation's session.

A helper request that contains a tool call of its own is recorded: a client
tool call in the response, or one in the history after the request's last user
message, which is the helper's instruction or the review request. Calls earlier
in the history belong to another conversation or to an earlier request on the
same thread. When a helper thread compacts, the summarization instruction Codex
adds does not count as that last user message, so the helper's calls before it
still count. Other `thread_source` values are recorded, including
`memory_consolidation` turns, because Codex's memory consolidation 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](#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.
Sessions are recorded as Qwen Code or Mistral Vibe. Their captures carry no
native conversation ID, so Watcher places them by their transcript. A
successful retry without a native ID can be graded again.
Their auxiliary requests are not separately classified or excluded.

Qwen Code's managed-memory helper is recorded as a separate session. That
helper can contain copies of earlier messages and tool results. Its separate
session does not mean the user's resumed conversation lost its grade links.

Both clients rebuild parts of their system message when a conversation is
resumed. When matching the system message, Watcher ignores the lines of the git
snapshot each client generates (branch, changed files and recent commits). For
Mistral Vibe, it also ignores the random suffix of the generated scratchpad
path and the current date. Edits, commits or a branch switch between sessions,
a new temporary directory, or resuming on a later day therefore do not split
the conversation or detach earlier judgments. The stored message keeps its
original text. Other changes to earlier content can still create a separate
session because these captures have no native ID.

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`.
Policy does not support that configuration, because such a name can match a
translated Codex tool (see [Tool names and policy](#tool-names-and-policy)).
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 this cue matches:

| Cue | Accepted value |
| --- | --- |
| **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:

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

Version syntax is not checked. 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 this 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 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.

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 and
ChatGPT Desktop local Work 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](../supported-agents.md#through-aperture) 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. When the client closes a streamed
Anthropic Messages response before it finishes, Aperture's copy has no stop
reason. Watcher treats a tool call in that copy that has no input as a call the
client never received, and neither records nor grades it. A call whose input had
streamed is graded as usual, because the client may still run it. 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,
session naming, session recaps, WebFetch page summaries, 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. A session recap, which Claude
Code requests when you return to an idle session, or a session name is
recognized when the request's final user message is exactly Claude Code's
recap or naming instruction, or for naming, by a request with no tools whose
system prompt is Claude Code's identity followed by the naming instruction. A
message containing other text besides the instruction is recorded. A WebFetch page summary is recognized by a request with no tools, a
system prompt that is only Claude Code's identity, and a single user message
holding the fetched page and WebFetch's fixed closing instruction. The
conversation still records the WebFetch call and its result. 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` or `request_kind`). |
| **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 a Watcher session UUID. [Aperture session IDs](#aperture-session-ids) describes which values Watcher uses to match conversations. |
| `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 CLI's Gemini API-key mode 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.

```json
{
  "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](#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](https://tailscale.com/docs/aperture/configuration#hooks)
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](#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](#aperture-session-ids) 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](python-sdk.md#search-sessions), 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.
