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

Dev containers

A dev container has its own home directory, so the Watcher client installed on a developer's machine is not present inside it. Without the setup on this page, agent sessions in the container are neither reviewed nor recorded, and nothing warns you.

This page assumes a developer at the keyboard and a container that is rebuilt often. For unattended containers in CI or an agent fleet, which authenticate with an organization API key instead, see Containers.

The example assumes processes in the workspace are trusted. It does not stop them from reading Watcher credentials, changing hooks, or stopping Watcher. See Hardening dev containers for stronger deployment options and their limits.

Host sign-in

The container reuses the machine's Watcher sign-in when one exists. If the credentials directory does not exist, the template creates an empty directory before mounting it. The container still starts, but its setup script warns that Watcher is not signed in. Open the forwarded Watcher UI to sign in before relying on monitoring. The new credentials are stored in the host directory and remain available after rebuilding the container.

The template's mounts and lifecycle commands assume a macOS or Linux host.

The template

Two files, meant to be copied into a repository as .devcontainer/:

  • devcontainer.json declares the mounts, the forwarded review UI port, and the lifecycle commands.
  • watcher-devcontainer.sh installs the client and its agent hooks, starts the client detached, and waits until it answers.

The image in the template is a bare base with no coding agent in it. Merging these entries into the repository's own devcontainer.json is the normal path, and that file is where the agent comes from; copying the template wholesale gives you a monitored container with nothing to monitor.

Merging means adding the containerEnv, mounts, and forwardPorts entries. Preserve any existing initializeCommand, postCreateCommand, and postStartCommand values. When the existing commands do not use Watcher's or the coding agents' mounted directories, use the object form, which runs them in parallel and keeps both:

"initializeCommand": {
"repo": "<existing initialize command>",
"watcher": "<Watcher initializeCommand from the template>"
},
"postCreateCommand": {
"repo": "<existing post-create command>",
"watcher": "bash .devcontainer/watcher-devcontainer.sh"
},
"postStartCommand": {
"repo": "<existing post-start command>",
"watcher": "bash .devcontainer/watcher-devcontainer.sh"
}

The object form works for every lifecycle command field. If one command must finish before the other starts, run them in order. In particular, if the repository's post-create setup installs or configures Claude Code or Codex, its first access to the agent directories must follow Watcher's ownership repair:

"postCreateCommand": "bash .devcontainer/watcher-devcontainer.sh --prepare-only && <existing post-create command> && bash .devcontainer/watcher-devcontainer.sh"

The prepare-only invocation repairs ownership and seeds local settings without installing or starting Watcher. The full invocation repeats the repair, then installs Watcher and its hooks after the repository has installed its agents. For other ordering dependencies, call the commands from a wrapper script in the required order.

The setup script takes ownership of its own mount points, and that work belongs there rather than in an onCreateCommand. Two reasons, each of which costs a container that looks healthy and is not monitored. A lifecycle command that fails skips every later one, so a single unwritable path stops Watcher from installing at all. And onCreateCommand does not always run: when the base image is not yet cached on the machine, the dev container tooling can pull it and go straight to postCreateCommand, so the first run on a new machine is the one that breaks.

Three things to set before distributing it:

WhatWhereNotes
WATCHER_API_URLcontainerEnvYour deployment. Its hostname must match the one the machine is signed in to, since credentials are stored per backend hostname.
WATCHER_CLIENT_VERSIONcontainerEnvPin the client release matching your server, as in an MDM rollout. Empty takes the newest published release, which is not the same as the version an MDM fleet runs if that fleet is on a pre-release.
The container usermountsThe mount targets assume /home/vscode, the default user of the mcr.microsoft.com/devcontainers/* images.

The Linux client is published for x86_64 only, so on an Apple Silicon Mac the container runs under emulation: uncomment the runArgs line in the template. Emulation costs speed; it does not change behavior.

What is shared with the host, and what is not

A developer signs in once, on their machine, and never again per container or per rebuild. That takes one directory shared with the host.

PathWhere it livesWhy
~/.apollo_monitor/credentialsBind mount from the hostThe sign-in. Shared live so the container starts authenticated.
The rest of ~/.apollo_monitorNamed volume, private to the containerSettings, session and decision history, and the client's lock and health files. Survives rebuilds.
The coding agents' configuration (~/.claude, ~/.codex)Named volumes, private to the containerTheir sign-ins and Codex's hook confirmation, kept across rebuilds.

Why the credentials are shared and not copied

A live share keeps one sign-in true for every client that reads it. Signing in again on the machine reaches the container immediately, with no rebuild, and there is no second copy to go stale or to be left behind when the first one changes.

It also keeps you clear of refresh-token rotation. If your deployment is configured to hand back a new refresh token each time a client uses the old one, and to retire the old one as it does, then independent copies fight: whichever client refreshes first retires the token the other is holding. Clients reading one shared file never meet that, because there is only ever one token to rotate.

Share the directory, not the file. The client writes credentials by renaming a new file into place, which a file-level bind mount does not survive.

Two things to know before looking for a recovery command. There is no watcher auth login: a client signs in through its own review UI in a browser, so an ordinary re-sign-in happens on the machine's UI and the container picks it up on its next read. And re-seed the file, never the directory. Deleting ~/.apollo_monitor/credentials on the machine detaches every container already running, permanently: the container's bind mount still points at the directory that was deleted, so it sees an empty one whatever you put back. Those containers need rebuilding. Rewriting the file inside the existing directory, which is what signing in again does, is picked up fine.

Why the rest of the state is not shared

The rest of ~/.apollo_monitor assumes one client per directory, as Containers describes, so the template gives the container its own copy. It is a volume rather than nothing, so a rebuild does not discard the container's session history.

Do not bind-mount the agent's configuration from the host

Do not bind-mount the coding agent's configuration directory from the host on a machine running Watcher, even though that would carry the agent's sign-in over. The agent hooks are recorded in that directory as command lines holding the absolute path of the client binary, and the host's path does not exist inside the container. Mounting the host's directory gives the container hooks that cannot run, and the container's own watcher hooks install then rewrites the file with container paths, which breaks the hooks on the host. The template keeps that directory on a volume, so you sign the agent in once per container and that sign-in survives rebuilds.

Starting the client

watcher autostart uses a macOS login agent or a Linux systemd user service. A dev container has neither, so the template starts the client from the postCreateCommand and postStartCommand lifecycle commands instead: the first covers container creation, and the second covers every later start. When the client is already healthy, the script refreshes the hooks without starting a second process.

Two details in the script matter, both covered in more depth under Containers:

  • The client is started with setsid and no stdin. A dev container has a terminal, and a background process still attached to it is stopped by the operating system seconds later.
  • The script exits non-zero if the client does not start. Missing authentication produces a warning instead, so developers without Watcher on the host can still open the container and sign in through its forwarded UI.

The example deliberately stops at startup checks. For process supervision, authentication readiness, hook drift, volume loss, and end-to-end upload checks, see Runtime reliability.

Settings follow the organization, not the host

An admin does not have to ship review settings into the container. A client pulls the organization's managed client settings as soon as it starts, and again every few minutes, and replaces its local admin settings file with that document. A container inherits the fleet's policy from the deployment within seconds of coming up, and follows later changes to it without being rebuilt.

That is also why the template does not copy the host's settings in. The host gets its settings from the same place, so a copy would only go stale.

Settings a developer sets for themselves, in the local review UI, are per machine and are not shared, so the container starts with defaults for those and keeps its own across rebuilds.

To seed the default profile with the developer's existing overrides, add an environment variable and read-only mount alongside the template's existing entries:

"containerEnv": {
"WATCHER_LOCAL_SETTINGS_SEED": "/tmp/watcher-settings.local.yaml"
},
"mounts": [
"source=${localEnv:HOME}/.apollo_monitor/settings.local.yaml,target=/tmp/watcher-settings.local.yaml,type=bind,readonly"
]

The setup script copies that file into the container's private state after repairing the named volume's ownership. It does not replace a local settings file that already exists, so later edits remain local to the container. Remove the named Watcher state volume before rebuilding if you need to seed the file again.

Distributing it with an MDM rollout

An MDM rollout installs the client on the machine. It does not reach inside the containers that machine runs, and no MDM can: those are built from repositories the fleet's developers control. The rollout puts the sign-in on the machine, which every container on it then reuses.

Publish the template alongside the rollout, with WATCHER_API_URL and the pinned client version already filled in, so a team adding it to a repository does not have to know either.

Whether containers are covered depends on each repository. watcher doctor on the host reports on the host, not on containers started from it, so check that the template is present in the repository.

Codex

The client decides whether Codex is present by looking for ~/.codex, and it prints what it found. The template mounts a volume there, so that directory always exists and Codex is always treated as present: the client writes its hooks into ~/.codex/hooks.json and its approval_policy and sandbox_mode entries into ~/.codex/config.toml whether or not Codex is installed yet.

That is the useful direction to fail in. A developer who installs Codex later, inside an already-running container, finds it hooked, instead of unmonitored until the next container start. A container that never gets Codex carries an unused configuration, which does nothing. On a machine, where ~/.codex exists only once Codex does, the client skips Codex instead, without failing.

Codex asks each user to confirm the changed hooks the next time it starts, and records nothing until they accept. That confirmation, and the Watcher entries written into Codex's own configuration, live in ~/.codex, which the template keeps on a volume so they are answered once per container rather than once per rebuild. See Supported agents for what the hooks do in each agent.

A container with only one of the two agents can drop the other's volume from mounts; the setup script skips a mount point that is not there.

Verifying it works

In a terminal inside the container:

watcher doctor

Confirm it reports the deployment you expect, the agent hooks installed, and the client running and signed in. A prompt to sign in means the container is not reading the host's credentials: the usual cause is WATCHER_API_URL differing from the URL the host is signed in to, since credentials are stored per backend hostname.

Then prove recording end to end, which watcher doctor does not do. Run one agent turn in the container and 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, attributed to the developer whose sign-in the container is using.

Re-check with watcher doctor if the sign-in on the machine ever changes, because a container that loses access to it does not announce that. The client keeps answering as healthy, and where your organization settings allow tool calls through on a grading failure, they keep being allowed, logged as Live grading unavailable - allowed by policy. Nothing reaches the Analyzer for as long as that lasts.

Escalated tool calls wait in the container's own review UI, which is a different client from the machine's and shows only the container's sessions. It listens on 8228 inside the container, but not on 8228 on the host: the machine's own client already has that port there, so the editor forwards the container's to a free one and shows that port in its Ports panel.

Editor port forwarding is not the same as publishing a port. Containers explains that publishing 8228 with -p reaches nothing, because the UI binds loopback and only answers to localhost. Forwarding works where publishing does not: the editor connects to loopback from inside the container. Reach for forwardPorts, not appPort.