A desktop environment for AI agents, in one Dockerfile (locally and on Agent Substrate)

Agents are good at the web now, but I want to see what they are doing, and take over when they get stuck. This post builds a small Wayland desktop for an agent: Claude Code in a terminal on one side, Chromium on the other, and noVNC so I can watch from my own browser. It is one Dockerfile. Then it runs inside an Agent Substrate actor, which sandboxes it, keeps the API key out of it, and can suspend the whole desktop mid-task and bring it back.

Claude Code on the left reading a Wikipedia article that it opened in Chromium on the right

What's in the box

PieceChoiceWhy
CompositorSway, headless backend, Pixman rendererA real Wayland desktop with no GPU, DRM or input devices
BrowserDebian's Chromium with --ozone-platform=waylandNative Wayland, sandbox on, DevTools Protocol on 127.0.0.1:9222
AgentClaude CodeInteractive, so I can type to it through the same desktop
TerminalfootNative Wayland and renders on the CPU. GPU terminals like Ghostty, kitty or Alacritty need OpenGL, which here would mean slow software rendering
Browser toolsChrome DevTools MCPAttaches to the Chromium you are watching, not a hidden headless one
Computer usegrim, wtype, swaymsgScreenshots, typing and clicking for everything that isn't a web page
Remote viewwayvnc, websockify and noVNCwayvnc serves the desktop over VNC with a password. websockify bridges it to a WebSocket and serves noVNC, the VNC client that runs in your browser

There is no Xwayland and no X11. Everything except websockify (port 6080) listens on loopback inside the container.

The agent gets two ways to act. For the web it uses the MCP tools, which work on the DOM and the accessibility tree and are much more reliable than clicking pixels. For anything else on the screen it falls back to computer use: grim takes a screenshot that it can read, swaymsg seat seat0 cursor moves and clicks the pointer, and wtype types. A short CLAUDE.md in the image tells it so.

The Dockerfile

Everything is inline in a single dockerfile (download it here):

# syntax=docker/dockerfile:1
# A Wayland desktop for an agent: headless Sway, Chromium with the Chrome
# DevTools Protocol on loopback, Claude Code with Chrome DevTools MCP, and
# noVNC so a human can watch and take over.
FROM node:22-trixie-slim

RUN apt-get update \
 && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
      ca-certificates chromium chromium-sandbox curl dbus fonts-liberation \
      fonts-noto-cjk fonts-noto-color-emoji fonts-noto-core foot git grim jq \
      novnc sway wayvnc websockify wtype \
 && rm -rf /var/lib/apt/lists/*

RUN npm install -g @anthropic-ai/claude-code@2.1.321 chrome-devtools-mcp@1.12.0 \
 && npm cache clean --force

COPY <<'EOF' /etc/desktop/sway.conf
xwayland disable
output HEADLESS-1 mode 1600x900
seat seat0 fallback true
default_border pixel 2
EOF

COPY --chmod=755 <<'EOF' /usr/local/bin/desktop
#!/usr/bin/env bash
set -euo pipefail
export XDG_RUNTIME_DIR=/tmp/runtime-$(id -u)
mkdir -p -m 700 "$XDG_RUNTIME_DIR"
exec dbus-run-session -- /usr/local/bin/desktop-session
EOF

COPY --chmod=755 <<'EOF' /usr/local/bin/desktop-session
#!/usr/bin/env bash
set -euo pipefail
trap 'kill $(jobs -p) 2>/dev/null || true' EXIT

wait_for() {  # wait_for <seconds> <description> <command...>
    local deadline=$((SECONDS + $1)) what=$2; shift 2
    until "$@" >/dev/null 2>&1; do
        ((SECONDS < deadline)) || { echo "timed out waiting for $what" >&2; exit 1; }
        sleep 0.2
    done
}
window_open() { swaymsg -t get_tree | jq -e --arg id "$1" '[.. | objects | select(.app_id? == $id)] | length > 0'; }

# Headless wlroots with software rendering: no GPU, DRM or input devices needed.
export WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1
sway --config /etc/desktop/sway.conf &
wait_for 30 sway bash -c 'compgen -G "$XDG_RUNTIME_DIR/sway-ipc.*.sock" && compgen -G "$XDG_RUNTIME_DIR/wayland-*"'
export SWAYSOCK=$(compgen -G "$XDG_RUNTIME_DIR/sway-ipc.*.sock")
export WAYLAND_DISPLAY=$(basename "$(compgen -G "$XDG_RUNTIME_DIR/wayland-[0-9]")")

# Skip Claude Code's "use this API key?" prompt for the key we were given.
if [[ -n ${ANTHROPIC_API_KEY:-} ]]; then
    jq --arg k "$ANTHROPIC_API_KEY" '.customApiKeyResponses.approved += [$k[-20:]]' \
        "$HOME/.claude.json" > /tmp/claude.json && mv /tmp/claude.json "$HOME/.claude.json"
fi

# The agent's terminal on the left, Chromium on the right.
mkdir -p "$HOME/work"
foot --app-id agent-terminal --working-directory "$HOME/work" bash -l -c 'claude; exec bash -l' &
wait_for 30 "the terminal" window_open agent-terminal
chromium --user-data-dir="$HOME/.config/chromium" --ozone-platform=wayland \
    --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 \
    --disable-gpu --disable-quic --disable-dev-shm-usage --password-store=basic \
    --no-first-run --no-default-browser-check about:blank &
wait_for 60 Chromium window_open chromium
wait_for 30 "the DevTools endpoint" curl -sf http://127.0.0.1:9222/json/version

# VNC with a password. relax_encryption enables Apple Diffie-Hellman auth,
# which noVNC supports. Only websockify listens beyond loopback, on port 6080.
password=${VNC_PASSWORD:-$(head -c 12 /dev/urandom | base64)}
mkdir -p -m 700 "$XDG_RUNTIME_DIR/wayvnc"
cat > "$XDG_RUNTIME_DIR/wayvnc/config" <<CONFIG
enable_auth=true
relax_encryption=true
username=agent
password=$password
CONFIG
wayvnc --config "$XDG_RUNTIME_DIR/wayvnc/config" 127.0.0.1 5900 &
wait_for 30 wayvnc bash -c 'exec 3<>/dev/tcp/127.0.0.1/5900'
[[ -n ${VNC_PASSWORD:-} ]] || echo "noVNC password: $password"

mkdir -p /tmp/novnc
cp -rs /usr/share/novnc/. /tmp/novnc/
echo ready > /tmp/novnc/readyz
websockify --web=/tmp/novnc 6080 127.0.0.1:5900 &

wait -n
echo "a desktop process exited" >&2
exit 1
EOF

USER node
ENV HOME=/home/node CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 DISABLE_AUTOUPDATER=1
WORKDIR /home/node/work

COPY --chown=node:node <<'EOF' /home/node/.claude/CLAUDE.md
# Your desktop

You run in a terminal on a Wayland (Sway) desktop. A human may be watching it
over VNC. Chromium is open next to you.

- Prefer the `chrome-devtools` MCP tools for the web. They drive that same
  Chromium window, so the human sees what you do.
- For anything else on screen, use computer-use commands:
  - Screenshot: `grim screen.png`, then read it. The screen is 1600x900.
  - Window positions: `swaymsg -t get_tree | jq '.. | objects | select(.app_id?) | {app_id, rect}'`
  - Focus a window: `swaymsg '[app_id=chromium] focus'`
  - Click at x,y: `swaymsg seat seat0 cursor set X Y && swaymsg seat seat0 cursor press button1 && swaymsg seat seat0 cursor release button1`
  - Type: `wtype 'text'`; press a key: `wtype -k Return`
EOF

COPY --chown=node:node <<'EOF' /home/node/.claude/settings.json
{
  "permissions": {
    "allow": [
      "mcp__chrome-devtools",
      "Read(//tmp/**)",
      "Bash(grim:*)",
      "Bash(swaymsg:*)",
      "Bash(wtype:*)"
    ]
  }
}
EOF

RUN claude mcp add --scope user chrome-devtools -- \
      chrome-devtools-mcp --browserUrl http://127.0.0.1:9222 --no-usage-statistics --no-performance-crux \
 && jq '.hasCompletedOnboarding = true | .theme = "dark" | .projects["/home/node/work"].hasTrustDialogAccepted = true' \
      ~/.claude.json > /tmp/claude.json && mv /tmp/claude.json ~/.claude.json

EXPOSE 6080
ENTRYPOINT ["/usr/local/bin/desktop"]

A few things worth pointing out:

  • Startup order. desktop-session waits for each piece before starting the next one, and only creates /readyz once Sway, the terminal, Chromium, the DevTools endpoint and VNC are all up. Substrate uses that file to decide when the desktop is ready.
  • VNC password. wayvnc offers RSA-AES auth that noVNC doesn't speak. relax_encryption=true turns on Apple Diffie-Hellman auth, which noVNC does support. It protects the password, but the session itself is not encrypted, so noVNC says "Connected (unencrypted)". Keep the port on loopback. Set VNC_PASSWORD, or the container picks a random one and prints it.
  • Claude Code is pre-configured: the MCP server is added with claude mcp add, onboarding and the folder trust prompt are skipped, and whatever ANTHROPIC_API_KEY it starts with is pre-approved. The MCP and computer-use commands are allowed in settings.json, so it doesn't ask before each click.

Try it in Docker

docker build -f agent-browser-box.Dockerfile -t agent-browser-box .
docker run --rm --security-opt seccomp=unconfined \
  -e ANTHROPIC_API_KEY -e VNC_PASSWORD=change-me \
  -p 127.0.0.1:6080:6080 agent-browser-box

Open http://127.0.0.1:6080/vnc.html?autoconnect=true&resize=scale, log in as agent, click the terminal and give Claude something to do in the browser.

Why seccomp=unconfined? Chromium's sandbox needs user namespaces, and Docker's default seccomp profile blocks them. I would rather loosen seccomp than run Chromium with --no-sandbox. Under Substrate this flag isn't needed: gVisor and the microVM runtime both let the sandbox work as is.

In plain Docker, the agent also holds your real API key, and anything it runs can read it. That is the next thing to fix.

Run it on Substrate

Agent Substrate runs workloads like this as actors on Kubernetes. Three features matter here:

  1. Isolation. Each actor runs in gVisor or in a microVM (Cloud Hypervisor), not as a plain container.
  2. Suspend and resume. An actor's whole memory is snapshotted when it suspends. The desktop, the open tabs and Claude's conversation come back exactly where they were.
  3. Egress credential injection. The actor only has a placeholder API key. The egress gateway swaps in the real key on the way out, so it is never in the actor's memory, disk or snapshots.
NOTE

Credential injection on the agentgateway dataplane landed on Substrate's main after v1.0.0 (v1.0.0 supports it with Envoy only). I tested this with a build of main at 94e0a26a on a local kind cluster.

Install Substrate

From a clone of the Substrate repository, create a kind cluster and install Substrate with the agentgateway dataplane and the Kubernetes Secrets credential provider:

hack/create-kind-cluster.sh
export ATE_INSTALL_KIND=true
go run ./cmd/ate-setup deploy ate-system \
  --atenet-dataplane=agentgateway \
  --credential-provider='{"name":"k8s.io"}' \
  --ingress-allowed-spiffe-ids=spiffe://cluster.local/ns/ate-system/sa/atenet-client
go install ./cmd/kubectl-ate

# Worker images that match this build. Prints ateworker-gvisor: <ref> and ateworker-microvm: <ref>.
go run ./cmd/ate-setup --kind publish worker-images | tee worker-images.txt

If the host has /dev/kvm, the cluster script also sets up the microVM runtime.

Push the image

Push the image to the cluster's local registry and pin it by digest:

docker build -f agent-browser-box.Dockerfile -t localhost:5001/agent-browser-box:v1 .
DIGEST=$(docker push localhost:5001/agent-browser-box:v1 | sed -n 's/.*digest: \(sha256:[0-9a-f]*\).*/\1/p')
IMAGE=localhost:5001/agent-browser-box@$DIGEST

Worker pool and atespace

Pick a runtime, gvisor or microvm. The rest of the commands are the same for both.

RUNTIME=gvisor
WORKER_IMAGE=$(sed -n "s/^ateworker-$RUNTIME: //p" worker-images.txt)
kubectl create namespace agent-browser-box

if [ "$RUNTIME" = microvm ]; then
  CLASS='[{"name": "microvm", "configRef": {"name": "microvm"}}]'
  POD='{"resources": {"requests": {"cpu": "250m", "memory": "5Gi"}, "limits": {"cpu": "3", "memory": "5Gi"}}}'
else
  CLASS='[{"name": "gvisor"}]'
  POD='{}'
fi
kubectl apply -f - <<EOF
apiVersion: ate.dev/v1
kind: WorkerPool
metadata:
  name: agent-browser-box-$RUNTIME
  namespace: agent-browser-box
  labels: {workload: agent-browser-box-$RUNTIME}
spec:
  replicas: 1
  workerImage: $WORKER_IMAGE
  sandboxClasses: $CLASS
  template: $POD
EOF

kubectl ate create atespace agent-browser-box

The microVM pool asks for more memory because the worker pod also holds the VM's memory.

The API key

Put the real key in a Kubernetes Secret. The actor never reads this Secret. Only the credential provider does, when the egress gateway asks it to.

kubectl -n agent-browser-box create secret generic anthropic --from-literal=api-key="$ANTHROPIC_API_KEY"

The provider denies everything by default. Allow actors in the agent-browser-box atespace to use Secrets in the agent-browser-box namespace, and restart the provider to pick up the change:

kubectl -n ate-system patch configmap k8s-credential-provider-namespace-policy --type merge -p '
data:
  namespace-policy.yaml: |
    policies:
    - atespace: agent-browser-box
      allowedNamespaces: [agent-browser-box]
'
kubectl -n ate-system rollout restart deployment/k8s-credential-provider

The actor template

VNC_PASSWORD=$(head -c 12 /dev/urandom | base64)
echo "noVNC password: $VNC_PASSWORD"

kubectl ate create actor-template -f - <<EOF
metadata:
  atespace: agent-browser-box
  name: desktop-$RUNTIME
workerSelector:
  matchLabels: {workload: agent-browser-box-$RUNTIME}
containers:
- name: desktop
  image: $IMAGE
  env:
  - {name: ANTHROPIC_API_KEY, value: placeholder-the-gateway-replaces-this}
  - {name: NODE_EXTRA_CA_CERTS, value: /run/ate/egress-trust-bundle/trust-bundle.pem}
  - {name: VNC_PASSWORD, value: "$VNC_PASSWORD"}
  volumeMounts:
  - {name: system-info, mountPath: /run/ate/egress-trust-bundle}
  wakeupProbe:
    httpGet: {path: /readyz, port: 6080}
    timeoutSeconds: 120
volumes:
- name: system-info
  systemInfo:
    dataSources:
    - trustBundle:
        names: [egress-mitm.ate.dev, system-roots.ate.dev]
        path: trust-bundle.pem
resources:
  limits: {cpuMillicores: 2000, memoryBytes: 4294967296}
snapshotConfig:
  preferredFidelity: SNAPSHOT_FIDELITY_MEMORY
EOF
  • ANTHROPIC_API_KEY is a placeholder. Claude Code sends it in the x-api-key header, and the gateway replaces it.
  • To replace a header, the gateway has to terminate TLS for api.anthropic.com. The trustBundle volume mounts the gateway's CA next to the public roots, and NODE_EXTRA_CA_CERTS makes Claude Code trust it.
  • wakeupProbe is the /readyz file from the Dockerfile.
  • Creating the template boots the desktop once and takes a golden snapshot when it is ready. Every actor from this template resumes from that snapshot, in a couple of seconds, instead of booting Sway and Chromium again.

The actor and its egress policy

kubectl ate create actor desktop --template=desktop-$RUNTIME -a agent-browser-box

kubectl ate create egress-policy desktop -a agent-browser-box -f - <<EOF
rules:
- https:
    hostnames: [api.anthropic.com]
    effects:
      replaceHeaders:
      - header: x-api-key
        credentialUri: ate-secret://k8s.io/default/agent-browser-box/anthropic/api-key
- tlsPassthrough:
    hostnames: ["*"]
    ports: {numbers: [443]}
- http:
    hostnames: ["*"]
    ports: {numbers: [80]}
EOF

When several rules match, the most specific hostname wins. Requests to api.anthropic.com hit the https rule: the gateway decrypts them, swaps the placeholder for the Secret, and sends them on. Everything else Chromium does goes through tlsPassthrough untouched, so websites see Chromium's own TLS and nothing needs to trust the gateway CA. QUIC is blocked, so Chromium runs with --disable-quic.

The wildcards let the agent browse anywhere. For anything real, replace "*" with the sites the agent needs.

Connect

kubectl ate proxy actor desktop 6080:6080 -a agent-browser-box --address 127.0.0.1

Open http://127.0.0.1:6080/vnc.html?autoconnect=true&resize=scale and log in as agent with the password from above. The first connection resumes the actor from the golden snapshot.

Ask Claude to do something in the browser. In the egress gateway's logs you can see its requests to /v1/messages, and the gateway fetching the key from the credential provider:

kubectl -n ate-system logs deployment/atenet-egress | grep -E 'v1/messages|FetchSecret'

Suspend it mid-task

Disconnect noVNC (or close the tab), then:

kubectl ate suspend actor desktop -a agent-browser-box --mode sync
kubectl ate get actor desktop -a agent-browser-box

Once it is ACTOR_STATE_SUSPENDED, the worker is free for other actors. Reconnect noVNC and the actor resumes: same tabs, same scroll position, same Claude conversation. Claude can continue from there, and its next API call goes through the gateway as before.

The noVNC WebSocket does not survive the suspend, so disconnect first and reconnect after.

Things to know

  • The golden snapshot is shared. Everything that happens before /readyz is in the template's snapshot, so every actor from one template starts with the same VNC password and the same fresh Chromium profile. For a password per actor, create a template per actor.
  • The desktop is only as private as the proxy. Keep kubectl ate proxy on 127.0.0.1. Don't publish port 6080 with a Service or Ingress. The DevTools port never leaves the actor.
  • If Claude Code can't authenticate, check where the request stopped. "authentication rejected (401)" means the placeholder reached Anthropic, so the https rule didn't match. A 403 or 503 means the gateway couldn't fetch the Secret: check the Secret's name and key, the namespace policy, and that you restarted the credential provider after editing it.
  • Runtimes. The same image ran under gVisor and the microVM runtime, including suspend and resume in the middle of a task, with no changes.

Cleaning up

kubectl ate delete actor desktop -a agent-browser-box
kubectl ate delete actor-template desktop-$RUNTIME -a agent-browser-box
kubectl ate delete atespace agent-browser-box
kubectl delete namespace agent-browser-box

Or delete the whole kind cluster with hack/delete-kind-cluster.sh.