Every task runs its agent inside a container. That container's image is not one fixed blob — it is composed from three layers stacked in order: base → workflow → repo. The base stays small and general; a workflow adds what its skills need; a repo adds its own toolchain. This doc explains the model and gives copy-pasteable recipes for adding a workflow layer and a repo layer.
Each tier is a Dockerfile fragment, and the effective image is those fragments FROM-chained on top
of one another:
- Base — minimal and general:
python,git,gh,bash,curl,tmux; theclaudeCLI the agent execs; thepanopticonpackage; and the entrypoint (uid/gid remap → privilege drop). Built fromsrc/panopticon/docker/Dockerfileand taggedpanopticon-base(make build). It is deliberately not opinionated about any single workflow or repo;ghis shared because credentials and GitHub-bound work are workflow-independent. - Workflow — a fragment a
Workflowsubclass contributes via itsimage_layer()method, adding workflow-specific additions its skills need. It is empty when the base already supplies every required tool, as it is for the GitHub-forge workflows. - Repo — a fragment referenced by a repo's
image_layer_file, adding repo-specific setup: build toolchain, dependencies, pre-launch configuration (e.g.uv,make sync).
The repo layer sits on top because it is the most specific and most frequently changing.
When the session service (the runner) spawns a task, it composes the image before docker run
(sessionservice/spawner.py _compose_image, sessionservice/images.py):
- It fetches the two layers over REST from the task service —
GET /workflows/{name}/image-layerandGET /repos/{id}/image-layer— and drops any that are empty. - If none remain, the task runs on
panopticon-basedirectly (no build). - Otherwise it writes a Dockerfile that starts
FROM panopticon-baseand appends the fragments, thendocker builds it, taggedpanopticon-<workflow>-<repo_id>.
Every spawn first compares the base image's content/version fingerprint with the packaged
Dockerfile, entrypoint, and Panopticon release, rebuilding the static panopticon-base tag when it
is missing or stale. Docker layer-caches that rebuild and the composed image, so unchanged inputs
are cheap. Change a layer and the next spawn rebuilds only the affected steps.
Each published Panopticon release also produces a multi-platform copy of that base image at
ghcr.io/unsupervisedcom/panopticon-next:<version> and updates latest for a non-prerelease. The
published image is built from the same release wheel and carries the same base fingerprint, so it
is an immutable distributable form of the locally built panopticon-base. The runner continues to
manage its local panopticon-base tag and does not implicitly pull from the registry.
Workflows whose
runner_typeis"shell"(e.g.setup-repo) run on the host with no container, so they have no image and layers are ignored.
Override image_layer() on your Workflow subclass to return a Dockerfile fragment string. The
default (core/workflow.py) returns "" — no layer. Everything the string contains is baked into
the image, so use it for system-level installs your skills depend on. For example, a workflow that
renders diagrams could add Graphviz:
def image_layer(self) -> str:
return "RUN apt-get update && apt-get install --yes --no-install-recommends graphviz"Notes:
- This is distinct from
tools()andskills().image_layer()puts a binary in the image;tools()just names an expected tool so the agent reaches for it, andskills()declares agent-driven procedures. A named tool may already be installed in the base image. - Spell external-program flags in full (
apt-get install --yes,--no-install-recommends) — they are self-documenting and grep-able. - Keep it small. The base stays general on purpose; only add what the workflow's own skills need.
A repo layer is operator-authored and referenced by name, so you don't touch code:
-
Write the fragment file under the layers directory —
~/.config/panopticon/layers/($PANOPTICON_CONFIG/layers;core/dirs.pyLAYERS_DIR). For example~/.config/panopticon/layers/myrepo.dockerfile:# Layered on top of base → workflow, so shared tools such as gh are already present. RUN curl --location --silent https://astral.sh/uv/install.sh | sh
It's a plain Dockerfile fragment —
RUN/ENV/COPYlines, noFROM(the composer supplies that). It builds on top of the workflow layer as the unprivilegedpanopticonuser's environment. -
Point the repo at it in the dashboard's repo form: set
image_layer_fileto the file's name (myrepo.dockerfile), not a path. The form offers a picker over the files in your layers dir, with a custom-path entry that normalizes to a name.
The value is a reference, not inline content — see the image_layer_file field in
repos.md. The task service resolves it against its layers dir and serves the content
over REST; the runner composes it onto base → workflow. Names that escape the layers root (..,
absolute paths) are rejected; nested names (team/myrepo.dockerfile) are allowed. An empty or unset
image_layer_file means no repo layer.
- The layer file lives on the host that spawns the container. Like
env_file(seedocs/auth.md),image_layer_fileis a bare name resolved against each runner host's own layers dir. With a single host that's your machine; with remote runners (M5), place a same-named file under each runner host's layers dir. - Rebuilds & cleanup. Editing a layer rebuilds the affected steps on the next spawn (Docker
caches the rest). A packaged base-input or version change also refreshes a stale base
automatically.
make cleanremovespanopticon-baseand every composedpanopticon-*image;make buildrebuilds the base immediately with the current development wheel. Changes to package code outside the fingerprinted Dockerfile/entrypoint/version still requiremake build. - Elevated privileges are a capability, not a layer. Docker-in-Docker is opt-in via the repo's
capabilitiesmap (docker_in_docker), which makes the runner spawn--privilegedand the entrypoint start a nested daemon — it is not something you add through a Dockerfile fragment. - Secrets never go in a layer. Image layers are cached and shared; keep API keys and tokens in
the repo's
env_file(docs/auth.md), injected at launch, not baked into the image.