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. - 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. An API key authenticates as the organization and has no user behind it, so uploading a session is a write on behalf of someone else. Without it, ingestion fails with 403 Ingestion via API key requires the 'sessions:write:any' permission.org:settings:readRequired only if the container should follow managed client settings. sessions:read:anyOptional. Needed only if something in the container reads sessions back from the Watcher API. -
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 socat \
&& 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 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.
socatis only needed if you want to reach the client's local UI from outside the container. See Reaching the review UI.
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 < /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
# Optional: expose the local UI on a second port bound to all interfaces.
if [ -n "${WATCHER_PUBLISH_UI:-}" ]; then
socat "TCP-LISTEN:${WATCHER_PUBLISH_UI},fork,reuseaddr,bind=0.0.0.0" \
"TCP:127.0.0.1:${PORT}" &
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. |
| Browser sign-in | A person is at the machine and the container is long-lived. | Publish the client's UI port (see below) and sign in from a browser on the host. |
| 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. |
| 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 waits for a person to approve it. A headless agent has nowhere to show that request, so it treats the escalation as a refusal and moves on: the call is lost and nobody is asked. Never leave escalation enabled in an unattended container.
Use the Auto posture instead: nothing 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.
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:
triage_max_allow_score: 6
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. triage_max_allow_scoreis the highest score the fast first-pass check may allow on its own. Above it, the call goes to the fuller review rather than being waved through.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 a grader's prompt so they score high enough to hit the deny threshold, and distribute it as organization policy. The custom grader prompt walks an agent through creating one against your deployment.
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.
There is a catch on the API-key path. The client only attempts the pull when it
believes it has a way to authenticate, and it decides that by looking for
browser sign-in credentials, which an API-key container never has. It therefore
skips the pull entirely. Setting WATCHER_DISABLE_WORKOS=1 tells the client
this deployment is not using browser sign-in at all, which clears that check
and lets the pull run on the API key. A container that should follow managed
settings needs both the variable and a key with org:settings:read.
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.
Reaching the review UI
The client serves its UI on port 8228 bound to 127.0.0.1, and it accepts
requests only for localhost. That is deliberate, and it means publishing 8228
with -p reaches nothing. To open the UI from the host, run the container with
-e WATCHER_PUBLISH_UI=8229 -p 8229:8229: the entrypoint relays the published
port to the loopback one, and http://localhost:8229 on the host serves the
UI, including pending approvals. Only do this where you would be willing to
expose the UI: anything that can reach that port can approve calls.
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:
tail -5 ~/.apollo_monitor/hook.log
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.
Two failure signatures worth recognizing:
In hook.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. |
403 Ingestion via API key requires the 'sessions:write:any' permission | The key authenticates but cannot write sessions. Grant the permission (see Prerequisites). |
Codex
Everything above applies to Codex, with one addition. Watcher manages two
settings in Codex's config.toml, 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.