Workbenches is an open standard. Read why
Docs Runtimes, models, and permissions

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.
  • docker and e2b require image: a published reference, or { build, context } to build from the package.
  • daytona requires class (only linux runs) and, to run, a published image. 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, or runtimes.docker.image when 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.

  • local compares them with the host.
  • docker turns cpu and memory_gb into container limits.
  • daytona sizes the sandbox from them, and gives an undeclared cpu or memory_gb 2 vCPU and 4 GiB. A runner server alone uses most of a GiB, so declare memory_gb when the work builds, installs dependencies, or runs a browser or game. On workbenches.dev, larger sandboxes use sandbox-hours faster.
  • gpu: true runs on local when the host has a GPU discovery can see, and on docker when the daemon has the NVIDIA container runtime, never on Docker for Mac. e2b and daytona refuse 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:

  1. 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.
  2. Contain every tool the manifest declares in tools, on PATH.
  3. Contain git and GNU tar with --null support, for e2b and daytona, which stage files with them. Repository runs also use git and gh.
  4. 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.
  5. 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 temporary HOME and /tmp. Point caches at HOME or bake them into the image.
  6. Expect /tmp and HOME to be non-executable in Docker: binaries come from the image, not from downloads at run time.
  7. Pin every version, including test tooling that must match the image. A caret range such as @playwright/test@^1.60 re-resolves to a newer release than the browsers baked into the image and breaks every test.
  8. 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 /workspace

For 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 .git file 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-terra

With 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.
  • model inside a route overrides the provider-native ID.
  • A model the catalog does not know needs explicit route model IDs and a packaged runner_config describing 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.json

Allow:

  • Reading and skills: read, skill, list, and grep, 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 (/workbench in Docker), and image tools live under /opt or /usr/local. Every path the work touches outside the workspace needs an external_directory allow.
  • Shell: the commands the work runs. A broad allow with explicit denies for destructive commands is the shape that works.

Deny:

  • Secret files: *.env and *.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.