# 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

```yaml
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](/docs/cloud/runs#preflight-reasons).

### 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:

```dockerfile
# 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:

```dockerfile
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](/docs/authoring/publishing#publish-an-image).

### 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

```yaml
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:

```yaml
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:

```yaml
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.

```json
{
    "$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.

```json
{
    "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.
