Every agent run gets a fresh sandbox built from its runner file, and Kiln destroys the sandbox when the run ends. A sandbox is always isolated: a Docker container or a Kubernetes pod. Kiln never runs an agent on the host (ADR-017).
Runner files
# runners/linux-node.yaml
description: Node 22 with pnpm
backend: docker
shape: { vcpus: 4, memoryGb: 8 }
setupCommands:
- corepack enable
- pnpm install --frozen-lockfile
egress:
- github.com
- registry.npmjs.org| Key | Default | Notes |
|---|---|---|
image |
Kiln's runner image | The container image. Docker pulls it if it is not cached. Without one, runs use the server's runner image (KILN_RUNNER_IMAGE), which has the agent CLIs. An image you name must have the harness CLI (claude, codex or opencode) on its PATH. |
backend |
docker |
docker or kubernetes. The server must have that backend. |
shape |
none | vcpus and memoryGb. Sets CPU and memory limits. |
setupCommands |
[] |
Run in order after the repositories are cloned, inside each repository (so pnpm install finds its package.json and the packageManager pin). A failing command fails the run. Kiln's runner image has a corepack that runs pnpm 11 and later. |
egress |
[] |
Extra host names the sandbox may reach, such as package registries and your model gateway. The hosts of the repositories it clones are always allowed. |
platform.linux.registryCredentialSecretName |
none | A team docker_registry or aws_ecr secret for a private image. See Private images. |
description |
none |
An agent picks a runner with runner: <name>, usually inherited from agentDefaults. An agent without a runner uses the server's runner image on the server's backend: docker, or kubernetes when the API runs with KILN_SANDBOX=kubernetes. A run's image is the runner's own, else its environment's, else KILN_RUNNER_IMAGE, else node:22-bookworm.
Backends
| Server setting | Backend | Needs |
|---|---|---|
KILN_SANDBOX unset or docker |
Docker | Docker running on the API's host, or its socket mounted into the API container. |
KILN_SANDBOX=kubernetes |
Kubernetes | A cluster the API can reach, with a gvisor RuntimeClass. |
There is no host backend. KILN_SANDBOX=local stops the server at start, a runner file with backend: local is rejected when you apply it, and kiln worker has no direct backend.
When the backend is not available, the run fails and says what is missing, for example Kiln needs Docker running to start a sandbox: Cannot connect to the Docker daemon .... Kiln does not fall back to the host, and it does not move a run to another backend: a runner with backend: kubernetes on a Docker-only server fails with a message that names the backends the server has.
A definition applied before this rule may still hold a runner with backend: local. It still loads, and its runs and work items still list. A new run on that runner fails with a message that says to set backend: docker or kubernetes and apply again.
In local mode (no forge connected, as with pnpm dev), the repositories are directories on the Kiln host. The Docker backend mounts each one into the sandbox at the same path so the agent can clone it and push back. This needs the API to run on the Docker host itself.
Docker
Each run is a container started with sleep infinity and /workspace as its working directory:
- labels
kiln.sandbox=1,kiln.dev/runandkiln.dev/factory, --cpusand--memoryfromshape,--security-opt no-new-privilegesand--cap-drop ALL,- the
kiln-egressnetwork withHTTPS_PROXYandHTTP_PROXYset tohttp://kiln-egress-proxy:3128(andNODE_USE_ENV_PROXY=1, so Node'sfetch, which corepack uses to download pnpm, goes through the proxy too) andKILN_EGRESS_ALLOWset to the allowed hosts: the runner'segresslist plus the hosts of the cloned repositories. Kiln gives the proxy the same list as this sandbox's own rule before anything runs in it, and removes the rule with the sandbox. With no hosts at all, the container gets--network none. - Studio sandboxes only: the
kiln-previewnetwork (KILN_PREVIEW_NETWORK) as well, where the API reaches their dev servers for live previews. On Kubernetes they get the labelkiln.dev/preview: "true"instead.
Kubernetes with gVisor
Each run is a pod in the kiln-sandboxes namespace, plus a NetworkPolicy for that pod:
runtimeClassName: gvisor, so the pod runs in gVisor's user-space kernel,runAsNonRootas user 1000,RuntimeDefaultseccomp, no privilege escalation, all capabilities dropped,- no service account token mounted,
restartPolicy: Never, - requests and limits from
shape, - an
emptyDirvolume at/workspace.
The NetworkPolicy blocks all ingress. When there are no allowed hosts at all, it blocks all egress too. Otherwise it allows only DNS (UDP 53 to kube-dns) and TCP 3128 to the pod labeled app.kubernetes.io/name: kiln-egress-proxy, which enforces the host list.
Kiln waits up to 120 seconds for the pod to be ready. Your cluster needs a gvisor RuntimeClass and the egress proxy deployed.
Egress
Agents need the network to clone, install packages and call model APIs. Kiln keeps that surface small:
- The hosts of the repositories the run clones, such as
github.com, are always allowed. - List every other host the run needs in
egress, such asregistry.npmjs.org. Nothing else is reachable. - An entry is a literal host name or address. A leading dot, as in
.example.com, also covers subdomains. - On Docker each sandbox has its own rule in the proxy (ADR-018): a host one run lists is not reachable from another run's sandbox, and an agent's
deniedHostsare blocked for its runs even when the server allows them. - The server's
KILN_EGRESS_ALLOWlist on the proxy is reachable from every sandbox. Keep it short. - On Kubernetes the proxy enforces the server list only: add the hosts your runners list to
sandboxes.egressAllowin the Helm values. - Kiln's API is always reachable from a run, at
KILN_SANDBOX_URL(the public URL by default). The command guard and the Kiln MCP server are there.
When the proxy is not running, a Docker run that needs the network fails with the sandbox egress proxy is not running on the Docker network kiln-egress and the command to start it: docker compose up -d egress-proxy. pnpm dev starts it for you.
- The proxy enforces host names, so you do not have to track IP addresses.
- Keep model traffic going to your model gateway, and list only the gateway.
What Kiln does in the sandbox
Kiln runs its own commands as trusted, so the command guard does not check them:
- configure git as
kiln-agent[bot]and exclude.kiln/from git, - check out the work item's branch, or the pinned commit for replays and benchmarks,
- write skills and harness settings,
- after the run, commit leftovers and push the branch with the run token.
Commands the agent runs through the sandbox's command API pass the command guard.
Cleaning up sandboxes
A sandbox is removed when its run ends, and also when cloning or setup fails. Sandboxes a restart cut off (a deploy or a crash during a run) are removed when the server starts, and a sweep every 10 minutes removes any that a failed clean-up left behind.
The sweep only removes sandboxes the server itself made: Docker containers labeled kiln.sandbox=1 with kiln.owner=kiln (or no owner, for containers from older versions). A kiln worker labels its containers kiln.owner=worker:<worker id>, so the server leaves them alone on a shared host. A paused run resumes from its snapshot in object storage, so removing its container after a restart loses nothing.
On a Docker host, images and build cache from earlier deploys also take space. Prune them from time to time:
docker system df
docker builder prune -af
docker image prune -afPause and resume
A running agent can be paused, for example while it waits at a gate, and resumed later on any node.
- Pause packs
/workspaceinto a.tgzarchive, stores it in object storage undersnapshots/<sandbox>/<time>.tgz, and stops the agent. The run's status becomespausedand the sandbox is kept until it resumes. - Resume starts a new sandbox from the same runner, possibly on another node or backend, unpacks the snapshot into
/workspace, and starts the harness again with the original request and any steering messages so far. - The run records the workspace hash at pause and at resume, so you can check the workspace is the same.
Snapshots hold files only. On Docker, the server can also be set to take a CRIU checkpoint that keeps processes; the snapshot records whether it did. Kubernetes snapshots never keep processes.
The Studio runner
Studio Apps run in their own image, kiln-runner-studio, so a new App needs no install. It is the runner image plus the Next.js template from templates/nextjs at /opt/kiln/templates/nextjs, with its node_modules installed, the pnpm store at /opt/kiln/pnpm-store and pnpm itself cached for corepack.
docker build -t kiln-runner-node:local runners/images/node
docker build --target studio --build-context template=templates/nextjs -t kiln-runner-studio:local runners/images/nodeStudio is off unless the API has KILN_STUDIO=1; without it, the Studio routes answer 503. Set KILN_STUDIO_IMAGE=kiln-runner-studio:local on the API. Without it, Studio uses KILN_RUNNER_IMAGE and creates the App with create-next-app, which downloads everything and takes minutes. runners/studio.yaml is the matching runner file.
The template is App Router, TypeScript, Tailwind CSS 4 and shadcn/ui, with AGENTS.md (and a CLAUDE.md that imports it) telling agents to keep the dev server running, use the components in components/ui/ and keep turns small. Its Studio settings are in templates/templates.json:
| Step | Command | Time |
|---|---|---|
| Create the App | cp -a /opt/kiln/templates/nextjs/. . |
1–3 s |
| Install in a chat worktree | hard-link the App's node_modules, then pnpm install --prefer-offline |
about 1 s |
| Start the preview | pnpm next dev --turbopack --port {port} --hostname 0.0.0.0 |
|
| Ready | GET / on the port answers |
4–5 s after start |
The first preview is up 7 to 10 seconds after the App is created. A worktree hard-links node_modules because importing 400 MB from a store in an image layer makes overlayfs copy every file (about 20 seconds). The template loads its Geist fonts from the geist package, so the dev server needs no host but registry.npmjs.org. When the server sets KILN_STUDIO_PREVIEW_DOMAIN, the template's next.config.ts allows *.<domain> as a dev origin, so hot reload works through the preview proxy.
To measure the boot on your machine:
pnpm tsx scripts/studio-template.ts kiln-runner-studio:local
KILN_STUDIO_BOOT_IMAGE=kiln-runner-studio:local pnpm vitest run scripts/studio-template.test.tsThe test is skipped unless KILN_STUDIO_BOOT_IMAGE is set and Docker answers.