Docker Container Lifecycle
Loop runs Claude Code agents inside Docker containers. This document covers how containers are created, configured, and managed throughout their lifecycle.
See also: Configuration Reference for global and project config fields that control container behavior.
Container Naming
Containers are named using the pattern:
loop-{base}-{6-hex-chars}- base is derived from the work directory’s basename (
filepath.Base(dirPath)) when a custom directory is set, or the channel ID otherwise. - The base is sanitized: lowercased, non-alphanumeric characters replaced with hyphens, consecutive hyphens collapsed, leading/trailing hyphens trimmed, and truncated to 40 characters.
- A random 3-byte (6-hex-character) suffix ensures uniqueness.
Examples:
- Channel
abc123with no custom dir:loop-abc123-a1b2c3 - Dir
/home/user/my-project:loop-my-project-d4e5f6
Custom Project Images
A project can run its agents in a custom image by setting container_image
in its .loop/config.json — typically an image built FROM loop-agent:latest
that bakes in extras (a corporate CA, internal CLIs). Keep the Dockerfile at
the conventional path .loop/container/Dockerfile.
Loop rebuilds these child images automatically whenever the base agent image is (re)built — at daemon startup after a version change, or via an image rebuild from the UI/API — so they never linger on an old base:
- Only images whose Dockerfile has a stage
FROMthe configured base image are managed; anything based elsewhere is never touched. - Staleness is tracked precisely with a
loop.parent_idlabel stamped at build time: a child is rebuilt exactly when its label differs from the current base image ID (pre-existing unlabeled images rebuild once). - Failures are logged per image and never block the daemon or other children.
- A child image that doesn’t exist yet, say right after a project sets
container_image, is built by the first agent run that needs it, before its container is created. A worktree uses its own Dockerfile, or else its project’s. When there’s no Dockerfile at all, Loop writes one to the project’s.loop/container/Dockerfilewith justFROMthe base image, to add to. - Loop builds only the
.loop/containeryou trusted (see Project config trust ), since the gate and the docker proxy run from inside the image. It copies the directory first and builds from the copy when every file in it matches the version you trusted. A directory holding just the Dockerfile Loop writes needs no trust. A worktree’s copy of its project’s trusted directory builds too. Otherwise the build fails, and the project waits for you to review it in Settings → Project. - Opt out per project with
"container_image_autobuild": false.
Running containers are unaffected; the next agent run picks up the new image. A new container waits while its own image, or the base agent image, is being built (see Container Creation ). The cascade marks the child images it will rebuild before the base build counts as finished, so none starts from a project image the cascade is about to replace, and each child is released as soon as its own rebuild is done. Another project’s rebuild doesn’t hold it.
Container Creation
The createAndStartContainer method orchestrates the full creation pipeline:
- Resolve work directory – defaults to
~/.loop/{channelID}/work, overridden by the channel’sdirPathif set. - Load project config – merges
{workDir}/.loop/config.jsonwith the global config (see Configuration: Project Config ). - Wait for the image – while the container’s own image or the base agent image is being built (the base, then the custom project images
rebuilt on it), creation waits for it to finish; the chat shows “Waiting for the
<image>image build to finish”, naming the image it waits for. Builds of other images – another project’s image, the Chrome sidecar image – don’t hold it. The Chrome image is built between the base and the project images (on a UI/API rebuild, and at startup when it is missing or out of date), so a run waiting for the base waits out that step too. Then the image’sloop.versionlabel is checked: an image built by an older Loop release than the running daemon is refused with an “out of date” error that asks for a rebuild, instead of starting a container whose in-image binaries may not understand the policy the daemon hands them (the Docker proxy exits on an unknown rule op). Images without the label, and non-release versions, are not compared. - Build environment variables – see Environment Variables .
- Build mounts – see Mount Processing .
- Write MCP config – see MCP Config Generation .
- Create container via Docker API.
- Copy files into the container – see File Copying .
- Start container.
All containers are labeled with:
app=loop-agent– identifies all Loop-managed containers.loop-channel={channelID}– associates the container with a specific channel.
Docker Host Configuration
Every container is created with:
ExtraHosts: ["host.docker.internal:host-gateway"]– enables containers to reach the host machine’s API server.Tty: false– avoids ANSI escape codes in output; logs are demultiplexed viastdcopy.StdCopy.
Environment Variables
The following environment variables are set on every container:
| Variable | Value | Description |
|---|---|---|
LOOP_CHANNEL_ID | Channel ID | Identifies which channel this container serves |
LOOP_API_URL | http://host.docker.internal{api_addr} | Loop API URL reachable from inside the container |
HOME | Host user’s home directory | Container HOME matches host HOME for path consistency |
LOOP_HOST_USER | $USER from host | The host username |
LOOP_HOST_UID, LOOP_HOST_GID | Host uid/gid | The entrypoint creates the agent user with these numeric IDs so files it writes on bind mounts keep host ownership |
TZ | IANA timezone (e.g. Europe/Bucharest) | Detected from $TZ, time.Now().Location(), /etc/timezone, or /etc/localtime symlink; falls back to UTC |
PATH | $HOME/.local/bin:$HOME/bin:$HOME/go/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin | Standard PATH with user-local bin and Go directories |
LOOP_CHOWN_PATHS | Colon-separated paths | Named volume mount targets and copy-file targets that need ownership adjustment by the container entrypoint |
LOOP_GATE_ENABLED | 1 | Set when the security gate is enabled. Entrypoint flips on this to exec /usr/local/bin/loop syscallwrap -- "$@" as root instead of plain gosu "$AGENT_USER" "$@" |
LOOP_GATE_POLICY_FILE | /etc/loop/gate-policy.json | Set when the security gate is enabled. Path inside the container to the gate’s policy JSON (bind-mounted read-only from {policyDir}/{CID}/gate-policy.json on the host). Read by loop syscallwrap parent before installing the filter |
LOOP_DOCKERPROXY_ENABLED | 1 | Set when the Docker HTTP proxy is enabled. Entrypoint starts loop dockerproxy & as root before any privilege drop |
LOOP_DOCKERPROXY_POLICY_FILE | /etc/loop/proxy-policy.json | Path inside the container to the proxy’s policy JSON (bind-mounted read-only). Read by loop dockerproxy at startup |
LOOP_DOCKERPROXY_UPSTREAM | /var/run/docker.sock.host | Path inside the container to the real daemon socket (bind-mounted :ro from the host’s /var/run/docker.sock). The proxy listens on /var/run/docker.sock (tmpfs) and reverse-proxies to this |
LOOP_DOCKERPROXY_NESTED_DIR | /run/loop-dproxy | Anonymous volume (removed with the container) where the proxy opens a second socket. Containers the agent starts with the docker socket mounted get this socket instead of the daemon’s; see Gates: Nested docker socket |
LOOP_DOCKERPROXY_READONLY_DIRS | <workDir>/.loop | Colon-separated host directories containers the agent starts may only mount read-only — the project .loop dirs (plus the worktree parent’s); see Gates: Project config stays read-only |
LOOP_DOCKERPROXY_BIND_ROOTS | the agent’s read-write same-path directory mounts | Colon-separated host directories under which binds of containers the agent starts are pinned to a volume, so a symlink swapped in after the policy check can’t redirect them; see Gates: Binds are pinned |
LOOP_DOCKERPROXY_BIND_HOST_PATHS | the roots whose host path resolves elsewhere | Colon-separated root=path pairs, e.g. /tmp/x=/private/tmp/x on macOS. Docker Desktop reports a bind volume’s directory resolved, so the proxy accepts this path when it checks a loop-bind-* volume |
Authentication
Exactly one of the following is set, with OAuth taking precedence:
| Variable | Condition |
|---|---|
CLAUDE_CODE_OAUTH_TOKEN | Set when claude_code_oauth_token is configured |
ANTHROPIC_API_KEY | Set when anthropic_api_key is configured and no OAuth token exists |
ANTHROPIC_BASE_URL | Set when anthropic_base_url is configured; localhost becomes host.docker.internal |
Proxy Forwarding
A container’s proxy comes from proxies.http_proxy and proxies.https_proxy in config, falling back per variable to the daemon’s own environment. Whichever wins, localhost addresses are rewritten:
://localhost:and://127.0.0.1:become://host.docker.internal:- Bare port values like
:3128becomehttp://host.docker.internal:3128
Both letter cases are set in the container from one resolved value, because tools inside disagree about which spelling they read.
Prefer config over the daemon environment
The daemon’s environment is fixed when loop serve starts, and the service daemon:start installs carries no proxy variables at all (see Daemon — Proxy
). A daemon launched before the proxy was exported resolves no proxy, and since a container’s environment is fixed the moment it is created, every container it creates is proxy-less for that container’s whole life — a restart fixes the next container, never an existing one.
Config is re-read on every run, so setting the proxy there makes it a property of the project rather than of how the daemon happened to be launched, and takes effect on the next agent turn with no loop daemon:restart:
// ~/.loop/config.json, or .loop/config.json in the project
{
"proxies": {
"http_proxy": "http://127.0.0.1:3128",
"https_proxy": "http://127.0.0.1:3128"
}
}When neither config nor its environment names a proxy, the daemon says so at startup, and again whenever it creates a container for a project that sets proxies.no_proxy — a setting only worth having behind a proxy.
When a proxy is resolved, NO_PROXY and no_proxy are ensured to include host.docker.internal, localhost, 127.0.0.1, ::1, 172.16.0.0/12, the channel’s Chrome sidecar hostname, and everything listed in proxies.no_proxy — on top of whatever the daemon’s own NO_PROXY carried, which is never discarded. The Loop API and the sidecar are reached directly; the CIDR covers every other container on a Docker bridge, which a proxy running on the Docker host has no route back into — proxying those would hang until the request timed out rather than failing fast.
Image builds
Loop’s image builds (the agent image, project images and the Chrome sidecar) resolve the same proxy from the global config and pass it to docker build as --build-arg HTTP_PROXY=…, both letter cases, with the same NO_PROXY list. Docker predefines these build args, so RUN steps get them with no ARG line in the Dockerfile. An explicit build arg wins over proxies.default in the host’s ~/.docker/config.json. With no proxy resolved, nothing is passed and that file still applies.
The CIDR does not cover sibling containers reached by name
172.16.0.0/12 looks like it exempts every container on a bridge network, and it does not. Go’s proxy matcher — http.ProxyFromEnvironment, and the same logic in most other runtimes — compares NO_PROXY entries against the hostname in the URL, before DNS resolves it. A request to http://my-service:4566 is matched as the literal string my-service, which is never tested against an IP range. So it goes to the proxy, which has no route onto the Docker network, and comes back 502. The CIDR only helps when a bare IP is dialled.
This is why the Chrome sidecar is passed by hostname rather than relying on the CIDR, and why a project whose agent talks to its own compose stack has to name those services:
// .loop/config.json in the project
{ "proxies": { "no_proxy": ["my-service", "my-cache"] } }Loop cannot discover these itself. The agent container is created before the project’s stack exists and is not on the network compose later creates, so there is nothing to enumerate at that point. Project values are appended to the global list, and the same list is written into the Docker CLI config below, so containers the agent creates inherit it.
Docker CLI Proxy Config
Forwarding the variables covers what the agent itself talks to. It does not cover containers the agent creates: Docker CLI proxy injection is client-side — the CLI reads proxies.default.{httpProxy,httpsProxy,noProxy} from its config file and stamps those into each container, and never passes on its own environment. (docker info reporting an HttpProxy is the daemon’s setting, used for pulling images; it does not reach containers.)
So when a proxy is configured, Loop also writes a Docker CLI config into the container before it starts, using the same rewritten values the container’s own env got:
- Location:
$DOCKER_CONFIGwhen the container env sets one, otherwise$HOME/.docker/config.json. proxiesis the only key written. The host’s config is not used as a template for it: that config carries machine-local settings —currentContextnaming a Docker Desktop context,credsStorenaming a helper binary — which are meaningless inside the container. AcurrentContextthat cannot be resolved makes everydockercommand abort before it does anything (unable to resolve docker endpoint), and a missing credential helper fails any pull that needs a lookup.- The file is written from scratch. The agent image ships no Docker CLI config, so there is nothing to preserve; a custom
container_imagethat bakes one in has it replaced. - Nothing is written when no proxy is configured — no file, and no Docker API call.
- The copy is extracted at
/and carries every ancestor directory, owned by the agent’s uid/gid. The daemon rejects a copy whose destination does not exist, and the home directory frequently does not exist at create time: the image has no/home/<user>, and the container mirrors the host home path, which only appears once a bind mount under it materialises.
Injection happens at image-build and container-create time, never at docker exec. A container created before the config existed keeps its empty proxy env for its whole life, so an already-running stack needs a docker compose down (or an equivalent recreate) before this takes effect — otherwise it reads as though the config were being ignored.
Custom Environment Variables
Additional variables from the envs config map are appended. Values support ~ expansion (resolved to the host user’s home directory). Values can be any JSON type; non-strings are stringified via fmt.Sprintf("%v", ...).
Mount Processing
Mounts are specified in the config as host_path:container_path[:mode] strings.
Processing Rules
Named volumes (source has no slashes, doesn’t start with
~or.) are passed through to Docker without host path expansion or existence checks. The container path still gets~expansion. Named volume container paths are added toLOOP_CHOWN_PATHS.Host path mounts have both host and container paths expanded (
~to home directory). If the host path does not exist, the mount is silently skipped.Git excludes auto-mount – if
git config --global --get core.excludesFilereturns a file that exists, it is bind-mounted read-only at the same path inside the container.Work directory – the resolved work directory is always appended as a bind mount (
workDir:workDir), ensuring the project is available inside the container.Worktree parent mount – when
ParentDirPathis set and the work directory is inside it (e.g./project/.worktrees/wt1inside/project), the parent directory is mounted instead of the work directory. This ensures the container sees the main.gitdirectory that git worktrees reference.Extra directories – when a channel has extra directories configured (
extra_dirs), each extra directory is bind-mounted into the container at its original path. The--add-dirflag is passed to the Claude CLI for each extra directory, making all roots available to the agent. For worktree channels, the parent project’sextra_dirsare inherited too: the worktree’s seededextra_dirs(which references the parent project) is unioned with the parent’s own list, so a worktree container mounts the same extra roots as the parent channel.
Named Volume Examples
"mounts": [
"loop-npmcache:~/.npm", // Docker named volume
"loop-gocache:/go", // Docker named volume
"~/.ssh:~/.ssh:ro", // Host path, read-only
"~/.gitconfig:~/.gitconfig:ro" // Host path, read-only
]Security Gate
When the config’s gates.agentgate.enabled flag is true (the default), the runner wires a per-container seccomp gate into the spawn pipeline. The seccomp filter and the agentgate.Server notify loop both live inside the container — no notify fd or host-side unix socket crosses the container boundary. See Configuration: Security Gate
for the rule model and Gates: Runtime wiring
for the full lifecycle.
Per-Container Policy File
Before ContainerCreate, the runner calls writeGatePolicyFile which:
- Creates a host directory
{policyDir}/{containerID}/. The serve command computespolicyDiras{cfg.LoopDir}/run(typically~/.loop/run) and passes it into the runner viaSetGatePolicyandSetDockerProxyDeps. The directory lives under the user’s home rather than/run/loopbecause macOS/runis on the read-only system volume; the same path works on Linux so there’s one code path for both OSes. - Marshals the gate rule subset (
default_decision,path_rules,command_rules,file_rules) to{policyDir}/{containerID}/gate-policy.jsonwith mode0640. The on-disk wire format is snake_case throughout, matching~/.loop/config.json. Two rules are injected intofile_rulesafter the config merge: the workspace allow, and — pinned at the head — adenyon/etc/loop/**so the policy file cannot be rewritten by a rule the agent authored in its own project config. - Adds a bind-mount
{hostPolicyPath}:/etc/loop/gate-policy.json:roand setsLOOP_GATE_ENABLED=1, andLOOP_GATE_POLICY_FILE=/etc/loop/gate-policy.jsonon the container env; the gate reports approvals against the always-setLOOP_CHANNEL_ID. The per-container bearer token (32-byte hex) is not in the env: it’s copied in before start as/run/loop/gate-token, root-owned and mode 0400, so only the in-container gate parent and docker proxy can read it. It is shared with the docker proxy layer when both are enabled.
The file is written before the container starts so the first in-container read always succeeds. On container removal, the runner calls gateResolver.Remove(containerID) which frees the Manager + token. Policy files are left on disk (overwritten next spawn with the same cid).
Entrypoint Branch
internal/container/image/entrypoint.sh checks for $LOOP_GATE_ENABLED after user setup and, when present, execs the loop syscallwrap subcommand as root — no gosu wrapper:
if [ "$LOOP_GATE_ENABLED" = "1" ] && [ -x /usr/local/bin/loop ]; then
exec /usr/local/bin/loop syscallwrap -- "$@"
fi
exec gosu "$AGENT_USER" "$@"The loop syscallwrap parent process stays as uid 0 so the agent (running under $AGENT_USER) cannot signal it with kill(2) — different-uid kill is EPERM. Privilege drop to the agent uid happens in the child process (see below), so claude itself still runs unprivileged.
Fork / Handshake
loop syscallwrap parent (root):
- Loads the gate policy from
LOOP_GATE_POLICY_FILE. - Builds an
httpapprover.Approveragainst$LOOP_API_URL+ the token in/run/loop/gate-token— approval clicks round-trip to loop-server over HTTP. - Creates a
socketpair(AF_UNIX, SOCK_STREAM, 0)and re-execs/proc/self/exewithLOOP_SYSCALLWRAP_MODE=child,ExtraFiles=[child-end],SysProcAttr.Credential={uid, gid}(looked up from$LOOP_HOST_USER), andSysProcAttr.Pdeathsig=SIGKILL. - Receives the handshake (channel id + notify fd via SCM_RIGHTS) on the parent-end of the socketpair, acks with a single
0x01byte, and runsagentgate.Serveragainst the notify fd until the child exits.
loop syscallwrap child (agent user):
runtime.LockOSThreadpins the thread so the filter is installed on a predictable OS thread.prctl(PR_SET_PDEATHSIG, SIGKILL)ensures the kernel SIGKILLs the child (and transitively claude) if the parent ever dies — belt-and-braces alongside the value the parent already set inSysProcAttr.- Installs the seccomp filter with
SECCOMP_SET_MODE_FILTER | SECCOMP_FILTER_FLAG_NEW_LISTENER | SECCOMP_FILTER_FLAG_TSYNC. - Sends the notify fd +
$LOOP_CHANNEL_IDover fd 3 via SCM_RIGHTS, reads the ack, andsyscall.Execs the target command (claude).
The filter is inherited by every descendant. An agent cannot disable the gate by unsetting its own env — the filter is already installed on its parent by the time claude starts.
File Copying
Files listed in copy_files are copied into each container after creation but before startup. This gives each container its own independent copy rather than sharing via a bind mount, avoiding corruption from concurrent writes.
~/.claude.json is always copied (prepended automatically), even when copy_files is empty or customized. As it’s copied, Loop merges in the consent flags an automated agent can’t answer interactively — hasCompletedOnboarding, bypassPermissionsModeAccepted, and per-workdir hasTrustDialogAccepted / hasCompletedProjectOnboarding — while preserving the user’s auth, so a Chat Terminal resumes the session straight to the prompt instead of stalling on the onboarding / bypass-consent / “trust this folder” dialogs.
Mechanism
Each file is:
- Expanded (
~to home directory). - Read from the host filesystem (non-existent files are silently skipped).
- Packaged as a tar archive with a single entry.
- Copied into the container at the file’s parent directory via
CopyToContainer.
Mount Collision Filtering
Before copying, the list is filtered by filterMountedCopyFiles: any file whose expanded path matches a bind-mounted container path is removed. This prevents “device or resource busy” errors from attempting to CopyToContainer onto a bind-mounted path.
File copy target paths are added to LOOP_CHOWN_PATHS so the container entrypoint can fix ownership.
MCP Config Generation
Each container run writes a per-channel MCP configuration file at:
{workDir}/.loop/mcp-{channelID}.jsonUsing per-channel files avoids race conditions when a parent channel and its threads share the same work directory.
Built-in “loop” Server
Unless the user has defined an MCP server named "loop", a built-in entry is added:
{
"mcpServers": {
"loop": {
"command": "/usr/local/bin/loop",
"args": ["mcp", "--channel-id", "<channelID>", "--api-url", "<apiURL>", "--log", "<workDir>/.loop/mcp.log"]
}
}
}Additional flags are appended conditionally:
--author-id <authorID>– when an author ID is available.--memory– when semantic memory is enabled in config.
User-Defined Servers
MCP servers from the config’s mcp.servers map are included. If a project config defines servers with the same name as global servers, the project version takes precedence (see Configuration: Merge Rules
).
Cleanup
The MCP config file is deleted from the host after the container run completes (in a deferred call).
Session Retry Logic
When a container run fails with a “Prompt is too long” error and a session ID exists:
- A compact request is sent first: the runner creates a new container with the
/compactcommand using the existing session ID. - After compaction succeeds, the original request is retried with the compacted session ID.
For other failures with an existing session:
- The runner retries once, sending only the latest message as the prompt (not the full message history).
- If the retry also fails, the original error is returned.
- API limit errors and a full Docker disk are not retried: the second container would fail the same way.
Run Failures
The container’s log stream merges stderr into stdout, and the runner parses it as Claude’s stream-json. Lines that aren’t JSON — stderr, whatever the entrypoint prints — are kept (the last 20 lines, up to 2000 bytes), so a run that fails says why:
- No result event – the error names the exit code and carries that output, e.g.
container exited with code 1: parsing claude response: no result event found; last output: …. Exit code 137 means the process was killed, so it readskilled — out of memory or out of disk space. - Error result without text – some error results (
error_during_execution,error_max_turns) have noresult; the error then shows the subtype, the result’serrors, and the output tail instead of an emptyclaude returned error:. - Docker out of disk space – the disk that fills is the Docker VM’s, not the host’s. Container create, file copies and the agent’s own writes (its session files under
~/.claude) then fail withno space left on device/ENOSPC, and the chat shows “Docker is out of disk space … then send again” withdocker builder prune -aas the first thing to try.
The chat shows every run error, see Orchestrator: Agent Execution .
Live Session Containers
A channel’s live session
runs in an agent container like a one-shot run’s, with the same image, mounts, env, MCP config and gate, but its command reads turns as stream-json on stdin (--input-format stream-json) instead of taking a prompt, and it doesn’t deny the CLI’s Monitor tool. The container is created with OpenStdin and StdinOnce, and the daemon attaches to it to write turns and read the stream.
- It is registered as an
agentcontainer of its channel, so it shows in the container list and is removedcontainer_keep_alive_secafter it stops. - It stops when the session ends: idle (
live_session_idle_sec), a restart, a stop, the channel’s delete, or daemon shutdown. - With
StdinOnce, Docker closes the process’s stdin when the daemon’s attach goes away, so a daemon crash ends the claude process instead of leaving it waiting. The stopped container is then cleaned up like any other: the reconcile loop schedules its removal. - A session whose transcript is gone (a stale
session_id) starts a new session instead of failing.
Container Registry
The ContainerRegistry is a centralized, in-memory registry that tracks all Docker containers (agent, shell, chrome) throughout their lifecycle. It serves as the single source of truth for container state and is the entry point for all container lookups.
Lifecycle States
| Status | Description |
|---|---|
running | Container is active and running |
stopped | Container has exited but hasn’t been removed yet |
pending-removal | Container is scheduled for removal after a delay |
Container Types
| Type | Singleton | Description |
|---|---|---|
agent | No | Claude Code agent containers — multiple per channel allowed |
shell | Yes | Terminal access containers — one per channel |
chrome | Yes | Chrome sidecar containers — one per channel |
Singleton types (shell, chrome) enforce at most one running instance per channel. Registering a duplicate returns the existing entry.
Docker Labels
All Loop containers carry these labels for identification:
| Label | Value | Purpose |
|---|---|---|
app | loop-agent | Identifies all Loop-managed containers |
loop-type | agent, shell, or chrome | Container type classification |
loop-channel | Channel ID | Associates the container with a channel |
Registration & Events
When a container is registered, updated, or removed, the registry broadcasts real-time events through the ContainerBroadcaster interface:
| Event | Trigger |
|---|---|
container.registered | New container added to registry |
container.status_changed | Container status transitions (e.g. running → stopped) |
container.removed | Container unregistered from registry |
Events are delivered to all connected WebSocket clients (no channel scoping — containers are a global concern).
Startup Restore
On server startup, Restore() populates the registry from Docker containers that survived a daemon restart. It queries Docker for containers with the app=loop-agent label and registers them without broadcasting events.
Reconcile Loop
RunReconcileLoop runs periodically (every 30 seconds) and:
- Queries Docker for live containers
- Removes registry entries for containers that no longer exist in Docker (stale entries)
- Syncs statuses for containers whose Docker state changed (e.g. running → stopped)
- Schedules removal for containers that just stopped
FindOrCreateShell
Terminal connections use FindOrCreateShell to get a shell container for a channel. It uses a per-channel mutex to prevent duplicate containers when multiple terminal panes connect simultaneously (double-checked locking pattern). A shell container still pending removal is taken back, its timer cancelled, so a pane opened within the keep-alive reuses it.
Container Removal
Scheduled Removal
ScheduleRemove marks a container as pending-removal, records the remove_at timestamp, and schedules actual Docker removal after a configurable delay (container_keep_alive_sec, default 300 seconds / 5 minutes). This keeps docker logs available for debugging shortly after a run finishes.
If a container is re-registered before the timer fires (e.g. restarted), the pending timer is cancelled automatically.
Cleanup on Shutdown
Cleanup() lists all containers with the app=loop-agent label and force-removes them. This is called during graceful shutdown to clean up lingering containers.
Chrome Sidecar Container
When browser_enabled is true (default), a Chrome container is started lazily on first browser tool use. Chrome runs in a dedicated Docker container with a port mapping (127.0.0.1:0 → 9222) so the host API server can connect via CDP (Chrome DevTools Protocol). Like agent containers, a new sidecar waits while its Chrome image may be rebuilt – from the start of a rebuild or of the startup image check until the Chrome image is done – so it isn’t created from the image the build is about to replace; project image builds don’t hold it. A running sidecar is reused as is.
Architecture
MCP (Docker) --HTTP POST--> Host API --CDPClient--> Chrome (:9222 → mapped port)
Browser Pane --WebSocket--> Host API --CDPClient--> ChromeThe host API server manages all CDP connections centrally. The MCP server inside agent containers is a stateless HTTP proxy — it calls POST /api/browser/action on the host API for every browser operation. The browser pane connects via WebSocket for screencast frames and input events, with control operations also routed through the HTTP API.
Container Naming
Chrome containers follow the pattern loop-chrome-{sanitized-channel-id}. The same sanitization rules apply as for agent containers.
Persistent Profile
With browser.persist_profile (default true), each Chrome container mounts a named volume loop-chrome-profile-{sanitized-channel-id} at /profile and runs with --user-data-dir=/profile. Named volumes survive RemoveVolumes: true, so logins outlive the idle stop and the scheduled container removal. The volume is deleted by the browser pane’s Reset profile action and when the channel itself is deleted. See Browser
.
Extensions
browser.extensions lists host directories holding unpacked extensions. Each is bind-mounted read-only at /extensions/{index} — positional, because an unpacked extension’s ID is derived from its path, so a stable path keeps a stable ID and the extension’s stored state across restarts — and passed to Chrome as one --load-extension=/extensions/0,/extensions/1 flag. With the list empty the sidecar gets --disable-extensions instead. See Browser
.
Image Freshness
The chrome image carries loop.version / loop.built_at labels and is rebuilt with docker build --pull --no-cache when it is missing, unlabelled, or stamped with a different loop version — the same release-driven trigger as the agent image. Without that it would be built once and frozen, and Chromium would never pick up an upstream security fix. make docker-build rebuilds manually.
Built-in “loop-browser” MCP Server
When browser is enabled, a loop-browser MCP server is registered in the container’s MCP config:
{
"loop-browser": {
"command": "/usr/local/bin/loop",
"args": ["mcp-browser", "--log", "<workDir>/.loop/mcp-browser.log", "--api-url", "<apiURL>", "--channel-id", "<channelID>"]
}
}The MCP server proxies all 19 browser tools (navigate, screenshot, click, type, tabs, etc.) through POST /api/browser/action on the host API. Chrome is started lazily on first action. Screenshots can be returned as file paths (via a shared screenshots directory) instead of base64 for better performance.
Idle Timeout
Chrome containers auto-stop after 5 minutes of inactivity. Activity is tracked via:
- MCP tools: Each
POST /api/browser/actioncall touches the idle timer. - Browser pane: The desktop app’s browser panel signals
PaneConnected/PaneDisconnected— while a pane is connected, Chrome is never idle-stopped.
The idle monitor runs every minute and stops Chrome for sessions where paneCount == 0 and the last activity exceeds the timeout.
Resource Limits
Chrome sidecar containers run with:
- Memory:
browser.memory_mb, default 2048 MB (0for no cap). Docker fixes a container’s limit when it is created, so a change takes effect on the next sidecar — stop the running one to pick it up. - CPU:
browser.cpus, default 1.0 cores (0for no cap), fixed at creation the same way.
Both are resolved from the channel’s own config layers, so a project can raise or lift them for its own sidecars. Keeping a cap is what makes a runaway page attributable: the container is OOM-killed with State.OOMKilled set, instead of the kernel picking a victim across the whole Docker VM.
Shell Containers
CreateShellContainer creates a long-lived container for terminal access. Instead of running the Claude CLI, these containers execute sleep infinity.
Shell containers:
- Use the same
createAndStartContainerpipeline (same env, mounts, MCP config). - Are shared by every docker shell and agent pane of the channel, in any tab, as
docker execsessions. - Are marked for removal after
container_keep_alive_seconce no live session runs in them: the last pane with one closes, or its process ends (a shell left withexit). Closing one pane, or a tab, shown or not, leaves the container to the panes still open elsewhere; panes on a tab never opened have no session and don’t hold it. A session started while the last one is being released takes the container back. A session no app window holds any more is closed after 5 minutes (see Session Lifecycle ), which releases the container the same way. - Are removed at once by the layout’s Kill button, and marked for removal when their channel or thread is deleted.
Container Ensurer
ChannelContainerEnsurer provides a “find or create” pattern for terminal sessions:
- Lists running containers matching the
loop-channel={channelID}label. - If one exists, returns its ID.
- If none exist, creates a new shell container via
CreateShellContainer.
This ensures that terminal WebSocket connections always have a container to exec into, creating one on-demand if the channel has no running container.
Image Building
ImageBuild uses the Docker CLI (docker build) rather than the Docker SDK to leverage BuildKit and avoid “configured logging driver does not support reading” errors.
The build:
- Passes
CLAUDE_VERSIONas a build arg, fetched fromhttps://storage.googleapis.com/claude-code-dist-.../latest(falls back to a timestamp if the lookup fails). - Injects
~/.gitconfigas a build secret (if the file exists) so Git configuration is available during the build.
After a successful startup build, PruneBuildCache drops BuildKit cache entries unused for 30+ days so a long-lived install doesn’t balloon to 100GB+ of cache.
Reclaiming Docker Space
The Containers settings section exposes a “Reclaim Docker space” action. On open it shows, by source, what reclaiming would free (GET /api/image/reclaimable, read from Docker’s disk usage). Sizing volumes means walking them, which takes minutes on a large daemon, so the UI asks twice: once without volume sizes, back in seconds, then with ?volume_sizes=true. The action itself is POST /api/image/reclaim, and it doesn’t size volumes:
PruneBuildCache(unusedFor=0, all)drops all currently-unused BuildKit cache.PruneDanglingImagesremoves dangling (untagged) images — layers orphaned by repeated rebuilds.PruneAnonymousVolumesremoves the anonymous volumes no container uses. Named volumes, which hold caches and databases someone chose to keep, are never pruned.- The Chrome profile volumes (
loop-chrome-profile-*) of deleted channels are removed, when no container mounts them. - Opt-in, with
{"unused_images": true}in the body: the tagged images no container uses are removed too. Loop’s own are kept: the agent and Chrome images, the embeddings image, project images and the child images built on the agent image.
Removals aren’t forced, so an image or volume a container started using in the meantime stays. The handler returns a ReclaimResult (build_cache_reclaimed, images_reclaimed, unused_images_reclaimed, volumes_reclaimed, total_reclaimed, in bytes, and orphan_volumes_removed, a count, since Docker doesn’t say what removing a volume frees) and the UI reports the space freed. Build-cache pruning is daemon-global, not scoped to Loop’s own builds, and the next image build runs slower until the cache warms again.
Resource Limits
Containers are created with configurable resource limits:
| Config Field | Default | Docker Parameter |
|---|---|---|
container_memory_mb | 2048 | Resources.Memory (in bytes) |
container_cpus | 1.0 | Resources.CPUQuota / Resources.CPUPeriod |
container_timeout_sec | 3600 | Context timeout on the Run call |
Output Collection
Container output is collected in one of two modes:
Streaming Mode
When OnTurn is set on the agent request (streaming enabled):
- Container logs are followed in real-time via
ContainerLogsFollow. - Each newline-delimited JSON event is parsed and dispatched:
assistantevents triggerOnTurn(text) andOnToolUse(tool invocations).systemevents withtask_started/task_progresssubtypes triggerOnActivity.resultevents are captured as the final response.
- After log streaming ends, the runner waits for the container to exit.
Batch Mode
When OnTurn is nil:
- The runner waits for the container to exit.
- All logs are read at once via
ContainerLogs. - The same JSON parsing extracts the final
resultevent.
Both modes use a buffered scanner with a 1 MB max line size to handle large JSON events.