Skip to content
Docker Container Lifecycle

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 abc123 with 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 FROM the configured base image are managed; anything based elsewhere is never touched.
  • Staleness is tracked precisely with a loop.parent_id label 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/Dockerfile with just FROM the base image, to add to.
  • Loop builds only the .loop/container you 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:

  1. Resolve work directory – defaults to ~/.loop/{channelID}/work, overridden by the channel’s dirPath if set.
  2. Load project config – merges {workDir}/.loop/config.json with the global config (see Configuration: Project Config ).
  3. 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’s loop.version label 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.
  4. Build environment variables – see Environment Variables .
  5. Build mounts – see Mount Processing .
  6. Write MCP config – see MCP Config Generation .
  7. Create container via Docker API.
  8. Copy files into the container – see File Copying .
  9. 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 via stdcopy.StdCopy.

Environment Variables

The following environment variables are set on every container:

VariableValueDescription
LOOP_CHANNEL_IDChannel IDIdentifies which channel this container serves
LOOP_API_URLhttp://host.docker.internal{api_addr}Loop API URL reachable from inside the container
HOMEHost user’s home directoryContainer HOME matches host HOME for path consistency
LOOP_HOST_USER$USER from hostThe host username
LOOP_HOST_UID, LOOP_HOST_GIDHost uid/gidThe entrypoint creates the agent user with these numeric IDs so files it writes on bind mounts keep host ownership
TZIANA 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:/binStandard PATH with user-local bin and Go directories
LOOP_CHOWN_PATHSColon-separated pathsNamed volume mount targets and copy-file targets that need ownership adjustment by the container entrypoint
LOOP_GATE_ENABLED1Set 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.jsonSet 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_ENABLED1Set 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.jsonPath 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.hostPath 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-dproxyAnonymous 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>/.loopColon-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_ROOTSthe agent’s read-write same-path directory mountsColon-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_PATHSthe roots whose host path resolves elsewhereColon-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:

VariableCondition
CLAUDE_CODE_OAUTH_TOKENSet when claude_code_oauth_token is configured
ANTHROPIC_API_KEYSet when anthropic_api_key is configured and no OAuth token exists
ANTHROPIC_BASE_URLSet 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 :3128 become http://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_CONFIG when the container env sets one, otherwise $HOME/.docker/config.json.
  • proxies is the only key written. The host’s config is not used as a template for it: that config carries machine-local settings — currentContext naming a Docker Desktop context, credsStore naming a helper binary — which are meaningless inside the container. A currentContext that cannot be resolved makes every docker command 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_image that 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

  1. 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 to LOOP_CHOWN_PATHS.

  2. 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.

  3. Git excludes auto-mount – if git config --global --get core.excludesFile returns a file that exists, it is bind-mounted read-only at the same path inside the container.

  4. Work directory – the resolved work directory is always appended as a bind mount (workDir:workDir), ensuring the project is available inside the container.

  5. Worktree parent mount – when ParentDirPath is set and the work directory is inside it (e.g. /project/.worktrees/wt1 inside /project), the parent directory is mounted instead of the work directory. This ensures the container sees the main .git directory that git worktrees reference.

  6. 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-dir flag is passed to the Claude CLI for each extra directory, making all roots available to the agent. For worktree channels, the parent project’s extra_dirs are inherited too: the worktree’s seeded extra_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:

  1. Creates a host directory {policyDir}/{containerID}/. The serve command computes policyDir as {cfg.LoopDir}/run (typically ~/.loop/run) and passes it into the runner via SetGatePolicy and SetDockerProxyDeps. The directory lives under the user’s home rather than /run/loop because macOS /run is on the read-only system volume; the same path works on Linux so there’s one code path for both OSes.
  2. Marshals the gate rule subset (default_decision, path_rules, command_rules, file_rules) to {policyDir}/{containerID}/gate-policy.json with mode 0640. The on-disk wire format is snake_case throughout, matching ~/.loop/config.json. Two rules are injected into file_rules after the config merge: the workspace allow, and — pinned at the head — a deny on /etc/loop/** so the policy file cannot be rewritten by a rule the agent authored in its own project config.
  3. Adds a bind-mount {hostPolicyPath}:/etc/loop/gate-policy.json:ro and sets LOOP_GATE_ENABLED=1, and LOOP_GATE_POLICY_FILE=/etc/loop/gate-policy.json on the container env; the gate reports approvals against the always-set LOOP_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):

  1. Loads the gate policy from LOOP_GATE_POLICY_FILE.
  2. Builds an httpapprover.Approver against $LOOP_API_URL + the token in /run/loop/gate-token — approval clicks round-trip to loop-server over HTTP.
  3. Creates a socketpair(AF_UNIX, SOCK_STREAM, 0) and re-execs /proc/self/exe with LOOP_SYSCALLWRAP_MODE=child, ExtraFiles=[child-end], SysProcAttr.Credential={uid, gid} (looked up from $LOOP_HOST_USER), and SysProcAttr.Pdeathsig=SIGKILL.
  4. Receives the handshake (channel id + notify fd via SCM_RIGHTS) on the parent-end of the socketpair, acks with a single 0x01 byte, and runs agentgate.Server against the notify fd until the child exits.

loop syscallwrap child (agent user):

  1. runtime.LockOSThread pins the thread so the filter is installed on a predictable OS thread.
  2. 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 in SysProcAttr.
  3. Installs the seccomp filter with SECCOMP_SET_MODE_FILTER | SECCOMP_FILTER_FLAG_NEW_LISTENER | SECCOMP_FILTER_FLAG_TSYNC.
  4. Sends the notify fd + $LOOP_CHANNEL_ID over fd 3 via SCM_RIGHTS, reads the ack, and syscall.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:

  1. Expanded (~ to home directory).
  2. Read from the host filesystem (non-existent files are silently skipped).
  3. Packaged as a tar archive with a single entry.
  4. 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}.json

Using 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:

  1. A compact request is sent first: the runner creates a new container with the /compact command using the existing session ID.
  2. 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 reads killed — out of memory or out of disk space.
  • Error result without text – some error results (error_during_execution, error_max_turns) have no result; the error then shows the subtype, the result’s errors, and the output tail instead of an empty claude 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 with no space left on device / ENOSPC, and the chat shows “Docker is out of disk space … then send again” with docker builder prune -a as 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 agent container of its channel, so it shows in the container list and is removed container_keep_alive_sec after 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

StatusDescription
runningContainer is active and running
stoppedContainer has exited but hasn’t been removed yet
pending-removalContainer is scheduled for removal after a delay

Container Types

TypeSingletonDescription
agentNoClaude Code agent containers — multiple per channel allowed
shellYesTerminal access containers — one per channel
chromeYesChrome 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:

LabelValuePurpose
apploop-agentIdentifies all Loop-managed containers
loop-typeagent, shell, or chromeContainer type classification
loop-channelChannel IDAssociates 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:

EventTrigger
container.registeredNew container added to registry
container.status_changedContainer status transitions (e.g. running → stopped)
container.removedContainer 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:

  1. Queries Docker for live containers
  2. Removes registry entries for containers that no longer exist in Docker (stale entries)
  3. Syncs statuses for containers whose Docker state changed (e.g. running → stopped)
  4. 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--> Chrome

The 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/action call 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 (0 for 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 (0 for 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 createAndStartContainer pipeline (same env, mounts, MCP config).
  • Are shared by every docker shell and agent pane of the channel, in any tab, as docker exec sessions.
  • Are marked for removal after container_keep_alive_sec once no live session runs in them: the last pane with one closes, or its process ends (a shell left with exit). 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:

  1. Lists running containers matching the loop-channel={channelID} label.
  2. If one exists, returns its ID.
  3. 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_VERSION as a build arg, fetched from https://storage.googleapis.com/claude-code-dist-.../latest (falls back to a timestamp if the lookup fails).
  • Injects ~/.gitconfig as 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.
  • PruneDanglingImages removes dangling (untagged) images — layers orphaned by repeated rebuilds.
  • PruneAnonymousVolumes removes 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 FieldDefaultDocker Parameter
container_memory_mb2048Resources.Memory (in bytes)
container_cpus1.0Resources.CPUQuota / Resources.CPUPeriod
container_timeout_sec3600Context 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):

  1. Container logs are followed in real-time via ContainerLogsFollow.
  2. Each newline-delimited JSON event is parsed and dispatched:
    • assistant events trigger OnTurn (text) and OnToolUse (tool invocations).
    • system events with task_started/task_progress subtypes trigger OnActivity.
    • result events are captured as the final response.
  3. After log streaming ends, the runner waits for the container to exit.

Batch Mode

When OnTurn is nil:

  1. The runner waits for the container to exit.
  2. All logs are read at once via ContainerLogs.
  3. The same JSON parsing extracts the final result event.

Both modes use a buffered scanner with a 1 MB max line size to handle large JSON events.