Building Workbenches
Runtimes, models, and permissions
Declare every runtime the work can honestly run in, choose the model so consumers are not stuck without one particular key, and enforce in runner_config what the instructions promise.
Runtimes#
runtimes:
docker:
image:
build: ./Dockerfile
context: .
e2b:
image: images.workbenches.dev/acme/game-tools:1.4.0
daytona:
class: linux
image: images.workbenches.dev/acme/game-tools:1.4.0
local: {}
requirements:
os: [linux]
cpu: 4
memory_gb: 8- The first entry is the default. Consumers pick another with
--runtime. local: {}runs on the host: the runner and every tool must be installed there. Declare it only when host execution is intended.dockerande2brequireimage: a published reference, or{ build, context }to build from the package.daytonarequiresclass(onlylinuxruns) and, to run, a publishedimage. It cannot build a local Dockerfile and does not fall back to the docker image. OpenCode, Pi, and Claude Code all run on Daytona.- Cloud runs on workbenches.dev use
runtimes.daytona.image, orruntimes.docker.imagewhen the Daytona entry has none, and need a published image and an OpenRouter route. See Running on workbenches.dev.
Requirements#
Declare only what the work truly depends on: os, arch, cpu, memory_gb, disk_gb, gpu.
localcompares them with the host.dockerturnscpuandmemory_gbinto container limits.daytonasizes the sandbox from them, and gives an undeclaredcpuormemory_gb2 vCPU and 4 GiB. A runner server alone uses most of a GiB, so declarememory_gbwhen the work builds, installs dependencies, or runs a browser or game. On workbenches.dev, larger sandboxes use sandbox-hours faster.gpu: trueruns onlocalwhen the host has a GPU discovery can see, and ondockerwhen the daemon has the NVIDIA container runtime, never on Docker for Mac.e2banddaytonarefuse it.
Host Docker#
docker: { engine: { mode: host } } under the docker runtime asks for the host's Docker daemon. Consumers must pass --allow-host-docker on every run. Use it only when the work must run containers itself.
What happens at run time#
| Runtime | Workspace | Edits | Notes |
|---|---|---|---|
local |
The host directory | Written in place | Host tools and credentials. |
docker |
Mounted at /workspace (read-write); package at /workbench (read-only); named workspaces at /workspaces/<name> |
Written in place | Read-only root, /tmp tmpfs, HOME=/tmp/workbench-home, host uid and gid. |
e2b |
Staged in the sandbox | Pending until wb outcome --apply |
Fresh sandbox per run, 60 minute limit, 512 MiB transfer each way. Template built from the image and cached by tag. |
daytona |
Staged in the sandbox | Pending until wb outcome --apply |
Published image only, 60 minute limit, model keys from the environment only. |
The image contract#
Every image used by docker, e2b, or daytona must:
- Contain the runner at a pinned version:
opencode-ai@<version>,@earendil-works/pi-coding-agent@<version>, or@anthropic-ai/claude-code@<version>. Nothing can be installed at run time. - Contain every tool the manifest declares in
tools, onPATH. - Contain
gitand GNUtarwith--nullsupport, fore2banddaytona, which stage files with them. Repository runs also usegitandgh. - Run as any numeric user. The engine runs as the host's uid. Never rely on
USER, a home directory, or files owned by root being writable. - Tolerate a read-only root filesystem. Install everything under system paths at build time, world-readable (
chmod -R a+rX). Writable state lives only under the engine's temporaryHOMEand/tmp. Point caches atHOMEor bake them into the image. - Expect
/tmpandHOMEto be non-executable in Docker: binaries come from the image, not from downloads at run time. - Pin every version, including test tooling that must match the image. A caret range such as
@playwright/test@^1.60re-resolves to a newer release than the browsers baked into the image and breaks every test. - Say why each dependency exists, in a comment above it, and state the contract in the Dockerfile header.
A minimal OpenCode image:
# Runtime image for <name>. Runs as any numeric user with a read-only root:
# everything installs at build time, world-readable; state lives under HOME.
FROM node:22-bookworm-slim
ARG OPENCODE_VERSION=<pinned version>
# git and GNU tar: e2b and daytona stage the workspace with them.
# ca-certificates and curl: HTTPS from the runner and tools.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git tar ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# The runner.
RUN npm install -g opencode-ai@${OPENCODE_VERSION} \
&& chmod -R a+rX "$(npm root -g)"
# The tools this Workbench declares, pinned.
# RUN ...
WORKDIR /workspaceFor Claude Code, swap the runner line:
ARG CLAUDE_CODE_VERSION=<pinned version>
# The runner.
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
&& chmod -R a+rX "$(npm root -g)"Start from a base that already carries heavy dependencies when the work needs them, such as a browser automation image or a language toolchain image, and add the runner and tools on top. Build for the architecture consumers run: sandbox providers run linux/amd64, so build with --platform linux/amd64 on Apple silicon.
Publish images with wb image push and reference them by a versioned tag, never latest. See Publishing.
Workspaces and git#
- A git worktree does not work in
docker: its.gitfile points at the parent repository, which is not mounted. Tell users to give containerized runs a full clone. - If the work commits, say in the instructions to commit at every working step, so a crash costs minutes, not hours.
Models and routes#
model.id is provider-neutral, lab/model, such as openai/gpt-5.6-terra. It must exist in the model catalog: wb init --model <id> rejects unknown IDs, and wb view shows the resolved routes.
Pick the model from trial evidence on this Workbench's real tasks. Until there is evidence, keep the existing choice and report it as unevaluated. Report cost per working result, not per run.
Leave routes out by default#
model:
id: openai/gpt-5.6-terraWith no routes, every provider that serves the model is allowed, the lab's own provider first. A consumer with the lab's key, an OpenRouter key, or a Vercel AI Gateway key can all run it. Consumers should never be stuck because they lack one provider's key.
Add routes only when a provider must be excluded or ordered:
model:
id: openai/gpt-5.6-terra
routes:
- provider: openai
- provider: openrouter- Only listed providers are allowed, in this order.
- Each must serve the model. Duplicate providers are an error.
modelinside a route overrides the provider-native ID.- A model the catalog does not know needs explicit route
modelIDs and a packagedrunner_configdescribing it.
A Workbench meant for cloud runs on workbenches.dev must name OpenRouter explicitly: an openrouter/ model ID, or an openrouter entry in routes. Leaving routes out is not enough for a cloud run.
When writing the Workbench's description, name the providers it was trialed with, so consumers know which keys are proven.
Runner choice#
| Runner | Use when | Limits |
|---|---|---|
opencode |
The default. | Supports remote MCP servers, permission config in opencode.json, and OpenCode's own sign-in flows, including a ChatGPT subscription for OpenAI models. |
pi |
You want Pi. | Remote HTTP MCP on Pi 0.99 or newer, native extension questions, no permission protocol. Needs a directory runner_config and has no first-run sign-in inside a run. Runs on local, Docker, E2B, and Daytona. |
claude-code |
You want Claude Code. | Requires Claude Code 2.1.318 or newer. Routes through Anthropic, OpenRouter, or Vercel AI Gateway. Remote HTTP MCP, steering at the next tool result, a JSON-file runner_config. A Claude subscription works on local and, through the consumer's own CLAUDE_CODE_OAUTH_TOKEN, on docker, e2b, and daytona. |
Permissions#
A headless run that hits a permission prompt stops and waits. To the person supervising it, that looks exactly like slow work. The package's runner_config decides which prompts happen, so get it right before anyone runs the Workbench detached.
OpenCode#
Ship opencode.json in the package and declare it:
runner_config: ./runner/opencode.jsonAllow:
- Reading and skills:
read,skill,list, andgrep, so the runner can load the package's own skills and references. - The package and tool paths: the package is mounted outside the working tree (
/workbenchin Docker), and image tools live under/optor/usr/local. Every path the work touches outside the workspace needs anexternal_directoryallow. - Shell: the commands the work runs. A broad allow with explicit denies for destructive commands is the shape that works.
Deny:
- Secret files:
*.envand*.env.*(allow*.env.example). - Destructive commands specific to the domain.
task, when the work must stay in one context and the runner should not spawn its own sub-agents.
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"read": {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow"
},
"skill": "allow",
"external_directory": {
"/workbench/**": "allow",
"/opt/acme-tools/**": "allow",
"/tmp/**": "allow"
},
"bash": {
"*": "allow",
"flyctl apps destroy*": "deny",
"flyctl volumes destroy*": "deny"
},
"task": "deny"
}
}Claude Code#
Ship a JSON file with Claude Code permission rules and an optional turn limit. Only permissions.allow, permissions.deny, and max_turns are accepted; any other field fails validation. Declared MCP servers, the outbox, and named workspaces are granted by the engine, so do not list them.
{
"permissions": {
"allow": [
"Read",
"Glob",
"Grep",
"Skill",
"Read(//opt/acme-tools/**)",
"Bash(npm test:*)",
"Bash(git diff:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(flyctl apps destroy:*)"
]
},
"max_turns": 200
}Permission keys and rule syntax are the runner's, not the Workbench standard's. Check the documentation of the runner version you pin.
Test it#
Run one tiny real task with --detach, then wb wait. Do not approve any prompt. If it stops on needs_input, read the pending request and add the missing allow. Repeat until a representative task finishes with no prompts. Never ship a config that relies on the consumer approving everything.
Other runtime rules#
State the runtime's constraints in the instructions. Never run a server in the foreground; ship a backgrounding tool. Use workspace-relative paths.