Containers
Teams run coding agents in containers, unattended in CI or in an agent fleet. The Watcher client runs there too, with three differences from a laptop install that this page covers: how the container authenticates, which review posture makes sense when nobody is watching, and what to keep on a volume so a rebuild does not wipe it.
For a dev container on a developer's machine that is already signed in, Dev containers has a copyable template that reuses that sign-in instead of issuing a key. If the workspace must be treated as untrusted, see Hardening dev containers for design options rather than a ready-made template.
This page is written for Claude Code. Codex works the same way with one extra consent step, described at the end.
What has to be true inside the container
The client is not only a set of hooks. The hooks ask the client's local backend for each blocking review decision, so the client has to be running as a process in the container. If it is not, the hooks fall through to the coding agent's own permission flow: the agent keeps working, nothing is recorded, and nothing warns you.
Three consequences shape everything below:
- The client starts from your entrypoint.
watcher autostartsupports macOS login agents and Linux systemd user services; a container has neither. Usewatcher --foregroundto run without installing a login agent. - The client must be detached from the terminal. A background process that
touches a TTY is stopped by the operating system. In a container started with
docker run -it, a plainwatcher &dies seconds later, silently, and you get the fall-through above. Start it withsetsidand no stdin. - State belongs on a volume. Credentials, settings, and the local session
and decision history live in
~/.apollo_monitor. Without a volume, every rebuild starts from nothing.
Prerequisites
-
A Watcher deployment the container can reach over the network.
-
An organization API key, unless the container will use one of the other credential options below. Admins create keys on the Analyzer's Organization page (see Organization settings). The key needs these permissions:
Permission Why sessions:write:anyRequired for full Watcher operation under an API key because decision writes need authority over the session. It also permits cross-user ingestion. The narrower sessions:ingestpermission is sufficient only for observe-only, unattributed collection.org:settings:readRequired only if the container should follow managed client settings. sessions:findings:read:any,sessions:content:read:anyOptional. Needed only if something in the container reads findings or content back from the Watcher API. A
sessions:ingestkey can add turns to any member's Claude Code or Codex conversation whose ID it sends, though it cannot read sessions. Revoke it if it leaks. See Organization settings. -
An x86_64 image. The Linux client is published for x86_64 only, so on Apple Silicon the image is built and run with
--platform linux/amd64under emulation. Emulation costs build and runtime speed; it does not change behavior.
The image
# syntax=docker/dockerfile:1
FROM --platform=linux/amd64 node:22-bookworm-slim
# The backend this machine reports to. The installer bakes it onto the agent
# hook command lines, so it is a build argument rather than a runtime variable.
ARG WATCHER_API_URL=https://watcher.example.com
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git tar procps \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
RUN useradd --create-home --shell /bin/bash dev
USER dev
WORKDIR /home/dev
ENV PATH="/home/dev/.local/bin:${PATH}"
RUN curl -fsSL https://github.com/ApolloResearch/watcher-bin/releases/latest/download/install.sh \
| bash -s -- --watcher-api-url "${WATCHER_API_URL}"
COPY --chown=dev:dev --chmod=0755 entrypoint.sh /home/dev/entrypoint.sh
ENTRYPOINT ["/home/dev/entrypoint.sh"]
CMD ["bash"]
Notes on this file:
- The client is installed as a normal user, not root, because the agent hooks
and
~/.apollo_monitorbelong to a user home. - The installer asks for consent before changing Codex's configuration. With no terminal attached, as during a build, it proceeds without prompting.
The entrypoint
#!/usr/bin/env bash
set -euo pipefail
PORT="${WATCHER_BACKEND_PORT:-8228}"
# Seed the admin settings baseline from a mounted file, if one is given.
# Mounting directly onto ~/.apollo_monitor/settings.yaml would have Docker
# create the parent directory as root, leaving the client unable to write its
# own state into it.
if [ -n "${WATCHER_SETTINGS_FILE:-}" ]; then
mkdir -p /home/dev/.apollo_monitor
cp "${WATCHER_SETTINGS_FILE}" /home/dev/.apollo_monitor/settings.yaml
fi
# setsid and </dev/null keep the client alive when the container has a TTY.
setsid watcher --foreground < /dev/null > /home/dev/watcher-client.log 2>&1 &
# Wait for the client before handing over: an agent that starts first races it,
# and its early tool calls are neither reviewed nor recorded. Failing here is
# deliberate. A container that starts the agent anyway runs it unmonitored.
for _ in $(seq 1 60); do
if curl -fsS "http://localhost:${PORT}/api/health" > /dev/null 2>&1; then
healthy=1
break
fi
sleep 1
done
if [ -z "${healthy:-}" ]; then
echo "watcher did not become healthy; see /home/dev/watcher-client.log" >&2
exit 1
fi
exec "$@"
Authenticating the container
The client has to prove who it is to your deployment. Pick by what the container is:
| Option | Use it when | How |
|---|---|---|
| Organization API key | The container is unattended, or nobody is signed in on the host. | Pass WATCHER_API_KEY at run time. The hooks and the client both read it from the environment. |
| Terminal sign-in | A person owns the container but there is no browser in or near it. | Run watcher login inside the container: it prints a URL and a code, and you approve from a browser on any other device. Sessions are attributed to the person who approved. See Signing in without a browser. |
| Reuse the host's sign-in | The container runs on a developer machine that is already set up. | Bind-mount the host's ~/.apollo_monitor/credentials directory, and nothing else from it. Dev containers has a complete template that does this. |
| External credential helper | Your proxy requires renewable bearer tokens from a company CLI. | Install the helper inside the container and configure it as the container user. Keep its writable state owned by that effective UID; mismatched bind-mount ownership is rejected. See External credential helpers. |
| Proxy headers | Your deployment authenticates through a reverse proxy. | Set the WATCHER_HEADER_* variables in the environment that runs watcher hooks install, as on any other machine. See Sending additional headers. |
Two traps with the bind-mount option:
- The URLs must match exactly. Credentials are stored per backend
hostname, so a host signed in to
https://watcher.example.comproduces nothing a container pointed at a different hostname for the same deployment can use. - Share the credentials directory only. The rest of
~/.apollo_monitorassumes one client per directory: it holds which client is running and on which port, the local session and decision database, and per-deployment health state. If you mount the whole directory, the host and container clients interfere with each other. Give the container its own copy of the rest, on a volume.
Sessions recorded under an API key are attributed to the organization, not to a person: the Analyzer shows no user for them. If you need to tell fleets apart, issue a key per fleet or per purpose.
The coding agent needs its own credentials, separately from Watcher. Pass its API key in the environment, or mount the agent's credentials directory, as you would for any containerized agent.
Review posture without a human
An escalated tool call goes to the coding agent's own permission prompt, and
an unattended agent has no prompt to show. In a Claude Code claude -p run,
Watcher denies the escalated call at once, and its reason names the Auto
profile. Other runs without a prompt get the agent's own refusal. Either way
the call does not run and nobody is asked, so never leave escalation enabled
in an unattended container.
Use the Auto posture instead: no score escalates, and calls scoring at or
above 8 out of 10 are denied outright. The agent gets a clear "auto-denied by
policy" result it can act on, and dangerous calls are still stopped. Two kinds
of call still escalate under Auto: one that trips a
denial limit, and
one that matches a command rule sending it to a person, such as writing a
.watcher-ignore file. In an unattended container those
calls are denied.
watcher profile set auto selects it at run time; to bake it into an image,
mount this file and point WATCHER_SETTINGS_FILE at it:
version: 1
enforcement:
mode: enforce
policy_monitors:
on_grading_failure: allow
tool_thresholds:
"*":
flag_threshold: 11
deny_threshold: 8
unknown:
flag_threshold: 11
deny_threshold: 8
Bash:
flag_threshold: 11
deny_threshold: 8
PowerShell:
flag_threshold: 11
deny_threshold: 8
apply_patch:
flag_threshold: 11
deny_threshold: 8
Edit:
flag_threshold: 11
deny_threshold: 8
Write:
flag_threshold: 11
deny_threshold: 8
NotebookEdit:
flag_threshold: 11
deny_threshold: 8
WebFetch:
flag_threshold: 11
deny_threshold: 8
Skill:
flag_threshold: 11
deny_threshold: 8
Monitor:
flag_threshold: 11
deny_threshold: 8
AskUserQuestion:
flag_threshold: 11
deny_threshold: 11
EnterPlanMode:
flag_threshold: 11
deny_threshold: 11
ExitPlanMode:
flag_threshold: 11
deny_threshold: 11
command_rules:
rules: []
What the keys mean:
- A
flag_thresholdof 11 never escalates and adeny_thresholdof 11 never denies, so the two plan-mode entries andAskUserQuestionare always allowed. - Every tool is listed by name on purpose. A named entry takes precedence over
the
"*"glob, so setting the glob alone leaves the tools that ship with a lower flag threshold still escalating.unknownis a reserved key, not a tool: it covers any tool that no named entry and no glob matches, which is how a container handles tools added after the file was written. on_grading_failuredecides what happens when grading itself fails, for example when the grading provider is unreachable. The default isescalate, which is useless in an unattended container, so this file setsallow: ungraded calls run. Set it todenyinstead if you would rather a grading outage stop the agent than let it work unreviewed.
If you would rather watch before you block, set enforcement.mode to
observe: every call is graded and recorded, and the agent's own permission
rules decide what runs. Nothing is ever blocked in that mode, so treat it as a
tuning step rather than a destination. The modes are described in full under
enforcement modes.
Tune the graders to what containers get wrong
The thresholds decide what happens to a score; the graders decide the score. An unattended container has a different risk profile from a developer's laptop: no one notices a quietly disabled test, a scope creep into unrelated files, credentials read out of the environment, or an agent editing its own guardrails. Write those failure modes into the managed monitors' Custom Instructions so they score high enough to hit the deny threshold; they apply organization-wide.
Managed settings overwrite the file
Where your organization distributes managed client settings
through Watcher, the client replaces ~/.apollo_monitor/settings.yaml with the
organization's document within a few minutes of starting. A posture baked into
an image is therefore temporary: put anything that must survive into the
organization's document instead.
An API-key container can pull managed settings without browser sign-in. Give
its key org:settings:read; WATCHER_DISABLE_WORKOS is not required for these
pulls.
Running it
Build, then run. This example is the unattended shape: an API key, the Auto posture, and a named volume for state.
docker build --platform linux/amd64 \
--build-arg WATCHER_API_URL=https://watcher.example.com \
-t agent-with-watcher .
docker volume create watcher-state
docker run --rm -it --platform linux/amd64 \
-v watcher-state:/home/dev/.apollo_monitor \
-v "$PWD/settings-auto.yaml:/preset/settings-auto.yaml:ro" \
-e WATCHER_SETTINGS_FILE=/preset/settings-auto.yaml \
-e WATCHER_API_KEY="$WATCHER_API_KEY" \
agent-with-watcher bash
Mount the settings file anywhere except inside ~/.apollo_monitor; the
entrypoint copies it into place.
That example drops you in a shell, which is what you want while setting the
container up. For an unattended run, replace -it ... bash with the agent
invocation the container exists to run, for example:
docker run --rm --platform linux/amd64 \
-v watcher-state:/home/dev/.apollo_monitor \
-v "$PWD/settings-auto.yaml:/preset/settings-auto.yaml:ro" \
-e WATCHER_SETTINGS_FILE=/preset/settings-auto.yaml \
-e WATCHER_API_KEY="$WATCHER_API_KEY" \
agent-with-watcher claude -p "run the test suite and fix any failures"
The entrypoint starts the client and waits for it before the agent command runs, so the agent's first tool call is already covered.
The client's local port
The client listens on port 8228 bound to 127.0.0.1, and it accepts only
requests addressed to localhost or 127.0.0.1. That is deliberate, and it
means publishing 8228 with -p reaches nothing. Do not publish the port or
relay it. It has no authentication, so anything that reaches it can change
the client's state, for example by snoozing
review. Escalated calls go to the coding agent's own permission prompt inside
the container.
Verifying it works
Inside the container:
watcher doctor
This confirms the target, the agent hooks, and that the client is running. It does not confirm that an API key works. On the API-key path, doctor checks only that a key is present, so an install with a wrong or unpermissioned key reports a clean bill of health while every upload fails. Do not treat a green report as proof that recording works.
Prove recording end to end instead. Run one agent turn, then check both ends:
watcher logs hooks -n 5
A working container logs a line per tool call, such as
[pre_tool_use] Permission evaluated: Bash -> allow. Then open the
Analyzer and confirm the session is there.
Three failure signatures worth recognizing:
| In the hooks log | Meaning |
|---|---|
watcher not running ... falling through to native permission flow | The client is not running. In a container with a TTY this is usually a client started without setsid. Nothing is being recorded or reviewed. |
watcher signed out ... falling through to native permission flow | The container has nothing that authenticates to its backend target: no sign-in credentials and no API key. The hooks decide nothing, so your coding agent's own permission prompt gates each call. Nothing is being recorded or reviewed. |
watcher auth rejected ... falling through to native permission flow | The backend has rejected a run of the container's requests, on a deployment that authenticates through a reverse proxy or with an x-api-key captured into the hook headers. For a reverse proxy the fix is the gateway or the identity headers, not a sign-in; a rejected captured key needs replacing. watcher doctor names the specifics. The hooks decide nothing until a request is accepted, and nothing is being recorded or reviewed in the meantime. A key supplied through WATCHER_API_KEY is different once the client has learned from the backend that it runs WorkOS mode: the hooks stay enabled and the key's rejection surfaces as grading failures, handled by your organization's on_grading_failure setting rather than by this signature. Against a server too old to report its auth mode, repeated rejections disable the hooks and log this signature for the environment key too. |
403 Service ingestion requires the 'sessions:ingest' or 'sessions:write:any' permission | The key authenticates but cannot write sessions. For a normal Watcher container, grant sessions:write:any (see Prerequisites). |
Codex
Everything above applies to Codex, with one addition. Watcher manages Codex's
approval policy in config.toml (and in enforce mode the sandbox and who
answers an approval request as well), and Codex asks you to confirm the new hooks
the next time it starts, running without them until you accept. In a container
that means an unattended Codex run records nothing unless the confirmation was
already accepted in an image layer or in mounted state. See
Supported agents for what the hooks do in each agent.