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

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 autostart supports 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 plain watcher & dies seconds later, silently, and you get the fall-through above. Start it with setsid and 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:

    PermissionWhy
    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/amd64 under 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_monitor belong 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.
  • socat is 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:

OptionUse it whenHow
Organization API keyThe 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-inA 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-inThe 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 headersYour 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.com produces nothing a container pointed at a different hostname for the same deployment can use.
  • Share the credentials directory only. The rest of ~/.apollo_monitor assumes 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_threshold of 11 never escalates and a deny_threshold of 11 never denies, so the two plan-mode entries and AskUserQuestion are 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. unknown is 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_score is 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_failure decides what happens when grading itself fails, for example when the grading provider is unreachable. The default is escalate, which is useless in an unattended container, so this file sets allow: ungraded calls run. Set it to deny instead 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.logMeaning
watcher not running ... falling through to native permission flowThe 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' permissionThe 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.