# iii-sandbox

> Spawn ephemeral microVMs and expose 14 sandbox::* triggers (lifecycle + filesystem) for isolated command execution and file ops.

| field | value |
|-------|-------|
| version | 0.19.0-next.1 |
| type | engine |
| repo | https://github.com/iii-hq/iii |
| author | iii |

## installation

```sh
iii worker add iii-sandbox@0.19.0-next.1
```

## configuration

```yaml
- auto_install: true
  default_cpus: 1
  default_idle_timeout_secs: 300
  default_memory_mb: 512
  image_allowlist:
    - python
    - node
  max_concurrent_sandboxes: 32
```

## readme

# iii-sandbox

Spawn ephemeral microVMs from worker code or the terminal. The daemon registers 16 `sandbox::*` triggers — 4 lifecycle ops, 10 filesystem ops, the one-shot `sandbox::run`, and `sandbox::catalog::list` — every one called via `iii.trigger()`. Each sandbox boots in a few hundred milliseconds, runs commands isolated from the host, and is reaped when idle. The overlay filesystem is discarded on stop.

> **Implementation:** `crates/iii-worker/src/sandbox_daemon/`. Ships inside the `iii-worker` binary; the engine starts the daemon as `iii-worker sandbox-daemon` when `iii-sandbox` appears in `config.yaml`.

**Use it for:** running untrusted code, AI-agent tool calls, one-shot scripts, per-request isolation.

**Don't use it for:** long-lived services (use a regular worker), durable stateful tasks (overlay is wiped on stop).


## Agent Quickstart

If you are an AI agent calling `sandbox::*`, start here. Two paths:

### One-call workflow (recommended): `sandbox::run`

Boots a VM, drops your code in `/tmp/run.{ext}`, runs the interpreter, captures
stdout/stderr, and stops the VM. One call, one response.

```json
{
  "trigger": "sandbox::run",
  "payload": {
    "image": "node",
    "code": "console.log('hello from sandbox')",
    "lang": "node"
  }
}
```

`lang` accepts `"node"`, `"python"`, `"shell"`, or a custom interpreter binary path.
Pass `keep_sandbox: true` if you want to keep the VM alive to inspect afterwards.

### Surgical workflow: `sandbox::create` -> `sandbox::fs::write` -> `sandbox::exec` -> `sandbox::stop`

Use this when you need fine-grained control over multiple operations on one sandbox.

```json
// 1. boot
{ "trigger": "sandbox::create", "payload": { "image": "node" } }
// 2. write a file (content accepts a UTF-8 string directly)
{ "trigger": "sandbox::fs::write", "payload": {
    "sandbox_id": "<uuid>", "path": "/home/app/main.js",
    "content": "console.log('hi')\n"
} }
// 3. run it (3 valid cmd shapes — pick whichever you like)
{ "trigger": "sandbox::exec", "payload": {
    "sandbox_id": "<uuid>", "cmd": "node", "args": ["/home/app/main.js"]
} }
// 4. tear down
{ "trigger": "sandbox::stop", "payload": { "sandbox_id": "<uuid>" } }
```

### Three accepted `cmd` shapes for `sandbox::exec`

```json
{ "cmd": "node /home/app/main.js" }                 // shell-line, shlex-split
{ "cmd": "node", "args": ["/home/app/main.js"] }   // classic POSIX argv
{ "argv": ["node", "/home/app/main.js"] }          // single argv array
```

Shlex is **not** bash — `cmd: "echo $HOME && pwd"` won't expand variables or chain
commands. Use `sandbox::run` with `lang: "shell"` for bash semantics.

### Two accepted `env` shapes for `sandbox::create`, `sandbox::exec`, `sandbox::run`

```json
{ "env": ["FOO=bar", "PATH=/usr/bin"] }   // wire shape (legacy)
{ "env": { "FOO": "bar", "PATH": "/usr/bin" } }   // agent-natural shape
```

### Error responses carry a self-healing payload

Every error returns JSON encoded inside `error.message` (see SandboxErrorWire docs).
Parse it once:

```js
const detail = JSON.parse(err.message);
// detail.code, detail.type, detail.message, detail.docs_url, detail.retryable
// detail.fix       — ready-to-send next-call payload, null if not auto-fixable
// detail.fix_note  — one-liner explaining why fix is null
```

If `detail.fix` is non-null, **merge** its fields into your original request and
resubmit with `await fn(mergedRequest)` — keep your original fields and let
`detail.fix` override only what it names; do not call `fn(detail.fix)` on its own.
For example `FsParentNotFound` returns `fix: { "parents": true }`, which you merge
into the original `sandbox::fs::write` / `sandbox::fs::mkdir` request rather than
replacing it. `detail.fix_note` spells out the merge. The error IS the recovery path.

### Error codes (anchors below)


## Host requirements

Sandboxes run as libkrun microVMs and need hardware virtualization on the host:

- **macOS:** Apple Silicon (M-series). Intel Macs can't boot sandboxes.
- **Linux:** `/dev/kvm` readable by the engine process.
- **Windows:** unsupported.

Hosts without hardware virtualization will fail `sandbox::create` with error `S300` and a stderr tail from the failed VM process. See `S300` in `docs/api-reference/sandbox.mdx` for the full diagnostic flow.

## Sample Configuration

```yaml
- name: iii-sandbox
  config:
    auto_install: true
    image_allowlist:
      - python
      - node
    default_idle_timeout_secs: 300
    max_concurrent_sandboxes: 32
    default_cpus: 1
    default_memory_mb: 512
```

`iii worker add iii-sandbox` appends this block to your `config.yaml`. Trim or extend `image_allowlist` and `custom_images` to control what callers can boot.

## Configuration

| Field | Type | Default | Description |
|---|---|---|---|
| `auto_install` | boolean | `true` | Pull the image from its OCI ref on first use when the rootfs isn't cached. Set `false` in air-gapped or pre-provisioned deployments — callers get `S101` and operators pre-pull with `iii worker add iiidev/<image>`. |
| `image_allowlist` | string[] | `[]` | **Fail-closed** list of image names that may be booted. Entries must be preset names (`python`, `node`) or keys from `custom_images`. Empty list denies everything — `sandbox::create` returns `S100` for every request. |
| `default_idle_timeout_secs` | number | `300` | Reap a sandbox when `now - last_exec_at` exceeds this. The reaper runs every 10 s. Per-request `idle_timeout_secs` on `sandbox::create` overrides. |
| `max_concurrent_sandboxes` | number | `32` | Hard cap on live sandboxes. The 33rd concurrent `sandbox::create` returns `S400`. Size by host RAM (default RAM per sandbox × cap ≤ available RAM). |
| `default_cpus` | number | `1` | vCPUs per sandbox when the request omits `cpus`. |
| `default_memory_mb` | number | `512` | RAM ceiling per sandbox when the request omits `memory_mb`. |
| `per_image_caps` | map | `{}` | Per-image hard caps. Each value is `{ max_cpus: N, max_memory_mb: N }`. Requests exceeding a cap return `S400`. |
| `custom_images` | map | `{}` | Deployment-specific images beyond the built-in presets. Map key is the name used in `image_allowlist` and the `image` field on `sandbox::create`; value is a fully-qualified OCI reference (e.g. `ghcr.io/acme/my-app:1.2.3`). Preset names (`python`, `node`) are reserved. See `docs/api-reference/sandbox.mdx`. |

## Triggers

All 16 triggers are dispatched via `iii.trigger({ function_id, payload, timeoutMs })`. Recommended `timeoutMs` is in each table; lifecycle ops have meaningful timeout pressure, filesystem ops generally don't (the daemon is local).

### Lifecycle (4)

#### `sandbox::create`

Boot a microVM and return a `sandbox_id`. Recommended `timeoutMs`: `300_000` (cold pull can take 5-30 s).

| Field | Type | Default | Description |
|---|---|---|---|
| `image` | string | required | Preset (`python`, `node`) or `custom_images` key. |
| `cpus` | number | `default_cpus` | vCPUs. Capped by `per_image_caps`. |
| `memory_mb` | number | `default_memory_mb` | RAM ceiling. Capped by `per_image_caps`. |
| `name` | string | none | Human label for `sandbox::list`. |
| `network` | boolean | `false` | Enable guest networking. |
| `idle_timeout_secs` | number | `default_idle_timeout_secs` | Override the per-sandbox idle reaper. |
| `env` | string[] | `[]` | `K=V` entries injected into the guest. |

Returns: `{ sandbox_id, image }`.

#### `sandbox::exec`

Run a command inside a live sandbox. Recommended `timeoutMs`: `35_000` (daemon's 30 s default + 5 s margin).

| Field | Type | Default | Description |
|---|---|---|---|
| `sandbox_id` | string (UUID) | required | From `sandbox::create`. |
| `cmd` | string | required | Executable name. |
| `args` | string[] | `[]` | argv tail. |
| `stdin` | string (base64) | none | Bytes piped to the process's stdin. |
| `env` | string[] | `[]` | `K=V` entries merged on top of the boot env. |
| `timeout_ms` | number | `300_000` | Per-exec deadline enforced inside the daemon. Sized for cold `npm install` / `pip install` / `cargo build`; pass a smaller value for probes and version checks. |
| `workdir` | string | guest home | Working directory. |

Returns: `{ stdout, stderr, exit_code, timed_out, duration_ms, success }`.

#### `sandbox::list`

Enumerate active sandboxes. Empty payload (`{}`). Returns an array of `{ sandbox_id, image, name, status, created_at, last_exec_at }`.

#### `sandbox::stop`

Tear down a sandbox and reclaim resources.

| Field | Type | Default | Description |
|---|---|---|---|
| `sandbox_id` | string (UUID) | required | Sandbox to stop. |
| `wait` | boolean | `false` | Block until the VM process has exited. |

Returns: `{ sandbox_id, stopped }`.

### One-shot (1)

#### `sandbox::run`

Boot a VM, run a code snippet, capture output, and auto-stop — in a single call. The fast path for agents; see the [Agent Quickstart](#one-call-workflow-recommended-sandboxrun) above for the full walkthrough. Recommended `timeoutMs`: `300_000`.

| Field | Type | Default | Description |
|---|---|---|---|
| `image` | string | required | Preset (`python`, `node`) or `custom_images` key. |
| `code` | string | required | Source written to `/tmp/run.{ext}` and executed. |
| `lang` | string | required | `node`, `python`, `shell`, or a literal interpreter binary path. No default. |
| `files` | object[] | `[]` | Extra files dropped in first, each `{ path, content }` (UTF-8). |
| `env` | string[] \| map | `[]` | Injected into the interpreter (both env shapes accepted). |
| `stdin` | string (base64) | none | Bytes piped to the interpreter's stdin. |
| `timeout_ms` | number | `300_000` | Per-run deadline. |
| `keep_sandbox` | boolean | `false` | Keep the VM alive after the run and return its `sandbox_id`. |

Returns: `{ stdout, stderr, exit_code, timed_out, duration_ms, success, sandbox_id? }`. `sandbox_id` is present only when `keep_sandbox: true`; otherwise the VM is stopped on success and on failure.

### Catalog (1)

#### `sandbox::catalog::list`

List the images this engine can boot — bundled presets plus operator-registered `custom_images`. Call it before `sandbox::create` / `sandbox::run` when you don't already know what's available (closes the `S100` "image not in catalog" loop). Empty payload (`{}`).

Returns: `{ images: [ { name, oci_ref, kind } ] }` where `name` is the value you pass to `image`, `oci_ref` is the pulled reference, and `kind` is `"preset"` or `"custom"`. Presets come first; custom entries follow, sorted by name.

### Filesystem (10)

All filesystem triggers take `sandbox_id` (UUID) plus operation-specific fields. Bumping the sandbox's idle clock is automatic — fs activity counts as liveness. Errors return `S2xx`.

#### `sandbox::fs::ls`

| Field | Type | Description |
|---|---|---|
| `sandbox_id` | string | Sandbox to operate in. |
| `path` | string | Directory to list. |

Returns: `{ entries: FsEntry[] }` where each entry has `{ name, is_dir, size, mode, mtime, is_symlink }`.

#### `sandbox::fs::stat`

| Field | Type | Description |
|---|---|---|
| `sandbox_id` | string | Sandbox to operate in. |
| `path` | string | Path to inspect. |

Returns: `{ name, is_dir, size, mode, mtime, is_symlink }`.

#### `sandbox::fs::mkdir`

| Field | Type | Default | Description |
|---|---|---|---|
| `sandbox_id` | string | required | Sandbox to operate in. |
| `path` | string | required | Directory to create. |
| `mode` | string | `"0755"` | Octal permissions. |
| `parents` | boolean | `false` | Create intermediate directories (`mkdir -p`). |

Returns: `{ created: boolean }`.

#### `sandbox::fs::write`

Write a file into the sandbox. The body is given by exactly one of `content` or `content_b64`:

- `content: "<utf-8 string>"` — a bare JSON string written verbatim. The agent-natural form for source/text.
- `content_b64: "<base64>"` — inline binary (decoded before write).
- `content: <StreamChannelRef>` — an object channel handle for large/streaming uploads from a programmatic caller (channel-paced, no envelope timeout cap).

| Field | Type | Default | Description |
|---|---|---|---|
| `sandbox_id` | string | required | Sandbox to operate in. |
| `path` | string | required | Destination path inside the guest. |
| `mode` | string | `"0644"` | Octal permissions for the new file. |
| `parents` | boolean | `false` | Create missing parent directories. |
| `content` | string \| StreamChannelRef | one of `content`/`content_b64` | UTF-8 string (inline) or a channel handle (streaming). |
| `content_b64` | string | one of `content`/`content_b64` | Base64-encoded inline binary body. |

Returns: `{ bytes_written, path }`.

#### `sandbox::fs::read`

Read a file out of the sandbox. Always returns a `content: StreamChannelRef` the caller can subscribe to for the full file bytes. For UTF-8 text files under 1 MiB, the response also includes an inline `body: string` so callers can short-circuit the channel subscription and use the body directly.

| Field | Type | Description |
|---|---|---|
| `sandbox_id` | string | Sandbox to operate in. |
| `path` | string | Path to read. |

Returns: `{ content: StreamChannelRef, body?: string, size, mode, mtime }`.

- `content` is always populated. The same bytes are delivered through it whether or not `body` is also set, so peers that statically type `content` as `StreamChannelRef` keep working unchanged.
- `body` is present (`Some`) for files that fit in the 1 MiB inline cap **and** decode cleanly as UTF-8. Absent (`None`) for large or binary files — read those through `content` instead.

#### `sandbox::fs::rm`

| Field | Type | Default | Description |
|---|---|---|---|
| `sandbox_id` | string | required | Sandbox to operate in. |
| `path` | string | required | Path to remove. |
| `recursive` | boolean | `false` | Recurse into directories (`rm -r`). |

Returns: `{ removed: boolean }`.

#### `sandbox::fs::chmod`

| Field | Type | Default | Description |
|---|---|---|---|
| `sandbox_id` | string | required | Sandbox to operate in. |
| `path` | string | required | Target path. |
| `mode` | string | required | Octal permissions (e.g. `"0644"`). |
| `uid` | number | unchanged | New owner UID. |
| `gid` | number | unchanged | New group GID. |
| `recursive` | boolean | `false` | Apply recursively to a directory tree. |

Returns: `{ updated: number }` — count of paths changed.

#### `sandbox::fs::mv`

| Field | Type | Default | Description |
|---|---|---|---|
| `sandbox_id` | string | required | Sandbox to operate in. |
| `src` | string | required | Source path. |
| `dst` | string | required | Destination path. |
| `overwrite` | boolean | `false` | Allow overwriting an existing destination. |

Returns: `{ moved: boolean }`.

#### `sandbox::fs::grep`

Recursive regex search.

| Field | Type | Default | Description |
|---|---|---|---|
| `sandbox_id` | string | required | Sandbox to operate in. |
| `path` | string | required | Root path. |
| `pattern` | string | required | Regex (RE2 syntax). |
| `recursive` | boolean | `true` | Walk subdirectories. |
| `ignore_case` | boolean | `false` | Case-insensitive match. |
| `include_glob` | string[] | `[]` | Gitignore-style include filter. |
| `exclude_glob` | string[] | `[]` | Gitignore-style exclude filter. |
| `max_matches` | number | `10000` | Stop after N matches. |
| `max_line_bytes` | number | `4096` | Truncate any single line longer than this. |

Returns: `{ matches: FsMatch[], truncated }` where each match has `{ path, line_no, byte_offset, line }`.

#### `sandbox::fs::sed`

Find-and-replace across files. Pass either `files` (explicit list) or `path` (walk like grep) — exactly one. Passing both, or neither, returns `S210`.

| Field | Type | Default | Description |
|---|---|---|---|
| `sandbox_id` | string | required | Sandbox to operate in. |
| `files` | string[] | `[]` | Explicit list of paths. Mutually exclusive with `path`. |
| `path` | string | none | Root path to walk. Mutually exclusive with `files`. |
| `recursive` | boolean | `true` | Walk subdirectories. Only meaningful with `path`. |
| `include_glob` | string[] | `[]` | Include filter (only with `path`). |
| `exclude_glob` | string[] | `[]` | Exclude filter (only with `path`). |
| `pattern` | string | required | Regex or literal (see `regex` flag). |
| `replacement` | string | required | Replacement string. |
| `regex` | boolean | `true` | Treat `pattern` as a regex; `false` for literal. |
| `first_only` | boolean | `false` | Replace only the first match in each file. |
| `ignore_case` | boolean | `false` | Case-insensitive match. |

Returns: `{ results: FsSedFileResult[], total_replacements }`.

## Example: create → exec → stop

```typescript
import { registerWorker } from 'iii-sdk'

const iii = registerWorker('ws://127.0.0.1:49134')

const { sandbox_id } = await iii.trigger({
  function_id: 'sandbox::create',
  payload: { image: 'python', cpus: 1, memory_mb: 512 },
  timeoutMs: 300_000,
})

const out = await iii.trigger({
  function_id: 'sandbox::exec',
  payload: { sandbox_id, cmd: 'python3', args: ['-c', 'print(2 + 2)'] },
  timeoutMs: 35_000,
})
console.log(out.stdout) // "4\n"

await iii.trigger({
  function_id: 'sandbox::stop',
  payload: { sandbox_id, wait: true },
})
```

```python
from iii import register_worker

iii = register_worker("ws://127.0.0.1:49134")

result = await iii.trigger({
    "function_id": "sandbox::create",
    "payload": {"image": "python", "cpus": 1, "memory_mb": 512},
    "timeout_ms": 300_000,
})
sandbox_id = result["sandbox_id"]

out = await iii.trigger({
    "function_id": "sandbox::exec",
    "payload": {"sandbox_id": sandbox_id, "cmd": "python3", "args": ["-c", "print(2 + 2)"]},
    "timeout_ms": 35_000,
})
print(out["stdout"])  # "4\n"

await iii.trigger({
    "function_id": "sandbox::stop",
    "payload": {"sandbox_id": sandbox_id, "wait": True},
})
```

## CLI

The `iii sandbox` subcommands wrap a curated subset of the lifecycle and fs surface:

```
iii sandbox run <image> -- <cmd> [args...]    # one-shot create+exec+stop
iii sandbox create <image> [--idle-timeout N] # boot, print sandbox_id
iii sandbox exec <sandbox_id> -- <cmd> ...    # exec into a live sandbox
iii sandbox list
iii sandbox stop <sandbox_id>
iii sandbox upload <sandbox_id> <local> <remote>
iii sandbox download <sandbox_id> <remote> <local>
```

Anything outside this set (e.g. `fs::grep`, `fs::sed`, `fs::chmod`) is reachable only via `iii.trigger()` from worker code.

## Errors

The daemon returns typed `SandboxError`s with S-codes embedded in the wire
payload (`{type, code, message, docs_url, fix, fix_note, retryable}`).

`error.docs_url` resolves to one of the anchors below. The anchor IDs are
case-sensitive HTML anchors so a regression test
(`sandbox_docs_anchor_stability`) can verify each `SandboxErrorCode` has a
documented home.

### Request validation

<a id="S001"></a>
#### S001 — invalid request
Bad UUID, missing required field, ambiguous `cmd`/`args`/`argv` combo, invalid
env var name. Check `error.message` for the specific cause.

<a id="S002"></a>
#### S002 — sandbox not found
The `sandbox_id` is well-formed but no sandbox with that id exists. Call
`sandbox::create` first.

<a id="S003"></a>
#### S003 — concurrent exec
Exec is serialized one-at-a-time per sandbox; another exec is already in flight.
If that exec is a long-running or FOREGROUND process (a server, `npm install`, a
build/watch), waiting will NOT
free the slot — it holds until the process exits or hits its `timeout_ms`
(default 300s). Detach servers with `nohup <cmd> > /tmp/out.log 2>&1 &` and read
progress via `sandbox::fs::read`, or `sandbox::stop` + `sandbox::create` to reset.
Retry-after-wait only helps for a short in-flight command.

<a id="S004"></a>
#### S004 — sandbox already stopped
The sandbox was reaped or explicitly stopped. Create a new one.

### Image catalog

<a id="S100"></a>
#### S100 — image not in catalog
The `image` value isn't a built-in preset (`python`, `node`) and isn't a key in
`sandbox.custom_images` of `iii.config.yaml`. Either pick a known preset or add
a custom_images entry.

<a id="S101"></a>
#### S101 — rootfs missing
The image is in the catalog but the rootfs isn't on disk. Operator action:
run `iii worker add <image-ref>` on the host.

<a id="S102"></a>
#### S102 — auto-install failed (transient)
Pull or extract of the image bundle failed. Often transient — retry.

### Exec runtime

<a id="S200"></a>
#### S200 — exec timed out
The `timeout_ms` window elapsed before the binary exited. Raise the timeout or
break the work into smaller steps.

### Filesystem

<a id="S210"></a>
#### S210 — fs invalid request
Mutually exclusive fields, missing required field, unsupported operation
combination. Check `error.message`.

<a id="S211"></a>
#### S211 — file not found

<a id="S212"></a>
#### S212 — wrong file type (expected file, found directory, or vice versa)

<a id="S213"></a>
#### S213 — already exists

<a id="S214"></a>
#### S214 — directory not empty

<a id="S215"></a>
#### S215 — permission denied

<a id="S216"></a>
#### S216 — fs i/o error

<a id="S217"></a>
#### S217 — invalid regex pattern

<a id="S218"></a>
#### S218 — fs channel aborted (transient)
The streaming channel closed before the operation completed. Retry.

<a id="S219"></a>
#### S219 — fs operation unsupported
The sandbox supervisor inside the VM is too old to implement this fs op.
Upgrade iii-worker.

### Platform

<a id="S300"></a>
#### S300 — VM boot failed
The microVM couldn't boot. Almost always missing virtualization on the host
(no `/dev/kvm`, Intel Mac, Windows). Check the stderr tail in `error.message`.

<a id="S400"></a>
#### S400 — resource limit exceeded
Capacity bound (`max_concurrent_sandboxes`, per-image caps) reached.


## See also

- [`docs/api-reference/sandbox.mdx`](https://github.com/iii-hq/iii/blob/main/docs/api-reference/sandbox.mdx) — full payload reference, S-code diagnostics, custom images, environment variables, troubleshooting.
- [`docs/how-to/developing-sandbox-workers`](https://github.com/iii-hq/iii/blob/main/docs/how-to/developing-sandbox-workers) — how worker processes themselves run inside isolated microVMs (different topic from this trigger surface).

## api reference

```json
{
  "functions": [
    {
      "description": "List bootable images: bundled presets plus operator-registered custom_images. Call this before sandbox::create when you don't know what's available.",
      "metadata": {},
      "name": "sandbox::catalog::list",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "`sandbox::catalog::list` request. No fields — kept as a struct (not `()`) so adding optional filters (e.g. `include_oci_refs: bool`) later is a non-breaking serde change.",
        "title": "CatalogListRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "CatalogEntry": {
            "description": "One row in the catalog response. `name` is the catalog key that callers pass to `sandbox::create` (or `sandbox::run`); `oci_ref` is the fully-qualified OCI reference the daemon will pull. `kind` lets agents distinguish bundled presets from operator-registered custom images without having to maintain their own preset allowlist.",
            "properties": {
              "kind": {
                "$ref": "#/definitions/CatalogEntryKind"
              },
              "name": {
                "type": "string"
              },
              "oci_ref": {
                "type": "string"
              }
            },
            "required": [
              "kind",
              "name",
              "oci_ref"
            ],
            "type": "object"
          },
          "CatalogEntryKind": {
            "oneOf": [
              {
                "description": "Bundled with the daemon binary. Stable identifier across releases.",
                "enum": [
                  "preset"
                ],
                "type": "string"
              },
              {
                "description": "Operator-registered under `sandbox.custom_images` in `iii.config.yaml`. Specific to this deployment.",
                "enum": [
                  "custom"
                ],
                "type": "string"
              }
            ]
          }
        },
        "properties": {
          "images": {
            "description": "Every image the daemon will accept on `sandbox::create.image`. Presets come first in stable order; custom entries follow, sorted by `name` for determinism so an agent that diffs two `catalog::list` responses sees stable output.",
            "items": {
              "$ref": "#/definitions/CatalogEntry"
            },
            "type": "array"
          }
        },
        "required": [
          "images"
        ],
        "title": "CatalogListResponse",
        "type": "object"
      }
    },
    {
      "description": "Create an ephemeral sandbox VM. `image` must be a preset (`\"python\"`, `\"node\"`) or a `custom_images` key from iii.config.yaml; OCI refs are NOT accepted unless they match a catalog key. `env` accepts both `Vec<\"K=V\">` and `{ K: V }` map shapes.",
      "metadata": {},
      "name": "sandbox::create",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "EnvShape": {
            "anyOf": [
              {
                "description": "Original wire shape: `[\"FOO=bar\", \"PATH=/usr/bin\"]`.",
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "additionalProperties": {
                  "type": "string"
                },
                "description": "Agent-natural shape: `{ \"FOO\": \"bar\", \"PATH\": \"/usr/bin\" }`. Iteration is sorted by key (BTreeMap) so two callers passing the same map get the same env-var ordering.",
                "type": "object"
              }
            ],
            "description": "Environment-variable input. Agents naturally pass `{ FOO: \"bar\" }` (matching Docker/npm/k8s mental models); the original wire shape was `Vec<\"K=V\">`. The untagged enum accepts both; `into_kv_vec()` normalises to the canonical `Vec<String>` the runner expects.\n\n`Default` is the empty vec form (the historical wire shape)."
          }
        },
        "examples": [
          {
            "env": {
              "NODE_ENV": "production"
            },
            "idle_timeout_secs": 600,
            "image": "node",
            "memory_mb": 512
          }
        ],
        "properties": {
          "cpus": {
            "default": null,
            "description": "vCPU count; daemon/image default applies when omitted.",
            "format": "uint32",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "env": {
            "allOf": [
              {
                "$ref": "#/definitions/EnvShape"
              }
            ],
            "description": "Environment entries injected into the VM. Accepts either a `Vec<\"K=V\">` (original wire shape) or a `{ K: V }` map. `handle_create` normalises to the `Vec<String>` shape the boot path expects before invoking the launcher."
          },
          "idle_timeout_secs": {
            "default": null,
            "description": "Auto-stop the VM after this many seconds of inactivity; daemon default applies when omitted.",
            "format": "uint64",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "image": {
            "description": "Catalog name of the image to boot. Bundled presets are `\"python\"` and `\"node\"`; pass either string verbatim. The only other accepted values are the literal keys of `sandbox.custom_images` in `iii.config.yaml` — set by the operator. Do NOT pass an OCI ref like `\"ghcr.io/iii-hq/node:latest\"` or `\"docker.io/library/node:20\"` unless that exact string is the catalog key. Unknown values return S100 with the allowed set in the error message.",
            "type": "string"
          },
          "memory_mb": {
            "default": null,
            "description": "Memory cap in MiB; daemon/image default applies when omitted.",
            "format": "uint32",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "name": {
            "default": null,
            "description": "Human label surfaced by `sandbox::list`; not an identifier.",
            "type": [
              "string",
              "null"
            ]
          },
          "network": {
            "default": null,
            "description": "Whether the VM gets outbound networking; daemon default when omitted.",
            "type": [
              "boolean",
              "null"
            ]
          }
        },
        "required": [
          "image"
        ],
        "title": "CreateRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "image": {
            "type": "string"
          },
          "sandbox_id": {
            "type": "string"
          }
        },
        "required": [
          "image",
          "sandbox_id"
        ],
        "title": "CreateResponse",
        "type": "object"
      }
    },
    {
      "description": "Execute a command inside a live sandbox. `cmd` accepts three shapes: (1) a shell-style line that gets shlex-split (`cmd: \"node -v\"`), (2) `cmd` + `args` (the POSIX shape, `cmd: \"node\", args: [\"-v\"]`), (3) an `argv` array (`argv: [\"node\", \"-v\"]`). Shell metacharacters in the shell-line shape are NOT interpreted; use `sandbox::run` with `lang: \"shell\"` for bash semantics. `env` accepts both `Vec<\"K=V\">` and `{ K: V }` map shapes.",
      "metadata": {},
      "name": "sandbox::exec",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "EnvShape": {
            "anyOf": [
              {
                "description": "Original wire shape: `[\"FOO=bar\", \"PATH=/usr/bin\"]`.",
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "additionalProperties": {
                  "type": "string"
                },
                "description": "Agent-natural shape: `{ \"FOO\": \"bar\", \"PATH\": \"/usr/bin\" }`. Iteration is sorted by key (BTreeMap) so two callers passing the same map get the same env-var ordering.",
                "type": "object"
              }
            ],
            "description": "Environment-variable input. Agents naturally pass `{ FOO: \"bar\" }` (matching Docker/npm/k8s mental models); the original wire shape was `Vec<\"K=V\">`. The untagged enum accepts both; `into_kv_vec()` normalises to the canonical `Vec<String>` the runner expects.\n\n`Default` is the empty vec form (the historical wire shape)."
          }
        },
        "examples": [
          {
            "args": [
              "/home/app/index.js"
            ],
            "cmd": "node",
            "env": {
              "NODE_ENV": "production"
            },
            "sandbox_id": "00000000-0000-0000-0000-000000000000",
            "timeout_ms": 300000
          }
        ],
        "properties": {
          "args": {
            "default": [],
            "description": "Argv tail passed to `cmd` (each entry is one argv slot).",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "argv": {
            "default": [],
            "description": "Alternative input shape: a single argv array where the first element is the binary and the rest are arguments. Mutually exclusive with `cmd` having whitespace OR `args` being set.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "cmd": {
            "default": "",
            "description": "The binary to execute. Accepts three shapes (`handle_exec` picks one and normalises `cmd`/`args` before passing to the runner):\n\n1. **Shell line**: `cmd: \"node /home/app/index.js\"`. If `cmd` contains whitespace AND `args` is empty AND `argv` is empty, `cmd` is shlex-split into `(head, tail)` and `head` becomes the binary while `tail` becomes the argv. 2. **cmd + args**: `cmd: \"node\", args: [\"-v\"]`. The classic POSIX shape; unchanged from earlier versions. 3. **argv array**: `argv: [\"node\", \"/home/app/index.js\"]`. Wins over `cmd`/`args` if non-empty; the first element is the binary, the rest are arguments.\n\nShlex is NOT bash. Shell metacharacters (`;`, `|`, `&&`, `>`, pipes, redirects, variable expansion) inside the shell-line shape are split as text, not interpreted. Use `sandbox::run` with `lang: \"shell\"` if you need bash semantics.",
            "type": "string"
          },
          "env": {
            "allOf": [
              {
                "$ref": "#/definitions/EnvShape"
              }
            ],
            "description": "Environment entries added to the child. Accepts either a `Vec<\"K=V\">` (the original wire shape) or a `{ K: V }` map. `handle_exec` normalises to `Vec<String>` before invoking the runner."
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          },
          "stdin": {
            "default": null,
            "description": "Base64-encoded bytes piped to the child's stdin.",
            "type": [
              "string",
              "null"
            ]
          },
          "timeout_ms": {
            "default": null,
            "description": "Kill-after window in ms. Defaults to 300_000 (5 minutes) — sized for cold `npm install` / `pip install` / `cargo build`. Pass a smaller value (e.g. 10_000) for probes and version checks.",
            "format": "uint64",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "workdir": {
            "default": null,
            "description": "Working directory inside the sandbox; image default when omitted.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "sandbox_id"
        ],
        "title": "ExecRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "duration_ms": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "exit_code": {
            "format": "int32",
            "type": [
              "integer",
              "null"
            ]
          },
          "stderr": {
            "type": "string"
          },
          "stdout": {
            "type": "string"
          },
          "success": {
            "type": "boolean"
          },
          "timed_out": {
            "type": "boolean"
          }
        },
        "required": [
          "duration_ms",
          "stderr",
          "stdout",
          "success",
          "timed_out"
        ],
        "title": "ExecResponse",
        "type": "object"
      }
    },
    {
      "description": "Change file permissions (and optionally owner) inside a sandbox. Example: { sandbox_id: \"...\", path: \"/home/app/script.sh\", mode: \"0755\" }",
      "metadata": {},
      "name": "sandbox::fs::chmod",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "examples": [
          {
            "mode": "0755",
            "path": "/home/app/script.sh",
            "sandbox_id": "00000000-0000-0000-0000-000000000000"
          }
        ],
        "properties": {
          "gid": {
            "default": null,
            "description": "Optional GID to chown to. Pair with `uid` for a full chown.",
            "format": "uint32",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "mode": {
            "description": "Octal permissions (e.g. `\"0644\"`, `\"0755\"`).",
            "type": "string"
          },
          "path": {
            "description": "Absolute path to modify inside the sandbox guest.",
            "type": "string"
          },
          "recursive": {
            "default": false,
            "description": "Apply recursively to all entries under `path`.",
            "type": "boolean"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          },
          "uid": {
            "default": null,
            "description": "Optional UID to chown to. Pair with `gid` for a full chown.",
            "format": "uint32",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          }
        },
        "required": [
          "mode",
          "path",
          "sandbox_id"
        ],
        "title": "ChmodRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "updated": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          }
        },
        "required": [
          "updated"
        ],
        "title": "ChmodResponse",
        "type": "object"
      }
    },
    {
      "description": "Search for a regex pattern in files inside a sandbox. Walks `path` recursively by default. Example: { sandbox_id: \"...\", path: \"/home/app/src\", pattern: \"TODO\" }",
      "metadata": {},
      "name": "sandbox::fs::grep",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "examples": [
          {
            "include_glob": [
              "**/*.js",
              "**/*.ts"
            ],
            "path": "/home/app/src",
            "pattern": "TODO|FIXME",
            "recursive": true,
            "sandbox_id": "00000000-0000-0000-0000-000000000000"
          }
        ],
        "properties": {
          "exclude_glob": {
            "default": [],
            "description": "Gitignore-style exclude filter applied relative to `path`.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "ignore_case": {
            "default": false,
            "description": "Case-insensitive match.",
            "type": "boolean"
          },
          "include_glob": {
            "default": [],
            "description": "Gitignore-style include filter applied relative to `path`.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "max_line_bytes": {
            "default": 4096,
            "description": "Maximum bytes per matched line before content is truncated with `…`. Default 4096.",
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "max_matches": {
            "default": 10000,
            "description": "Maximum number of matches before truncation. Default 10_000.",
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "path": {
            "description": "Root path to search inside the sandbox guest. Treated as a directory when `recursive: true`, else as a single file.",
            "type": "string"
          },
          "pattern": {
            "description": "Regex pattern (Rust regex syntax, anchored fragments allowed).",
            "type": "string"
          },
          "recursive": {
            "default": true,
            "description": "Descend into subdirectories under `path`. Defaults to true.",
            "type": "boolean"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          }
        },
        "required": [
          "path",
          "pattern",
          "sandbox_id"
        ],
        "title": "GrepRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "FsMatch": {
            "description": "Single grep hit. `line` is 1-based. `content` is already truncated to `max_line_bytes` (with a trailing `…`) if the original line was longer.",
            "properties": {
              "content": {
                "type": "string"
              },
              "line": {
                "format": "uint64",
                "minimum": 0,
                "type": "integer"
              },
              "path": {
                "type": "string"
              }
            },
            "required": [
              "content",
              "line",
              "path"
            ],
            "type": "object"
          }
        },
        "properties": {
          "matches": {
            "items": {
              "$ref": "#/definitions/FsMatch"
            },
            "type": "array"
          },
          "truncated": {
            "type": "boolean"
          }
        },
        "required": [
          "matches",
          "truncated"
        ],
        "title": "GrepResponse",
        "type": "object"
      }
    },
    {
      "description": "List directory contents inside a sandbox. Example: { sandbox_id: \"...\", path: \"/home/app\" }",
      "metadata": {},
      "name": "sandbox::fs::ls",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "examples": [
          {
            "path": "/home/app",
            "sandbox_id": "00000000-0000-0000-0000-000000000000"
          }
        ],
        "properties": {
          "path": {
            "description": "Absolute path of the directory to list inside the sandbox guest.",
            "type": "string"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          }
        },
        "required": [
          "path",
          "sandbox_id"
        ],
        "title": "LsRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "FsEntry": {
            "description": "Single directory entry returned by `FsOp::Ls` / `FsOp::Stat`. `mode` is the octal permission string (e.g. `\"0644\"`); `mtime` is Unix seconds. `is_symlink` reflects the entry itself — FS handlers never follow symlinks unless the op doc-comment says otherwise.",
            "properties": {
              "is_dir": {
                "type": "boolean"
              },
              "is_symlink": {
                "type": "boolean"
              },
              "mode": {
                "type": "string"
              },
              "mtime": {
                "format": "int64",
                "type": "integer"
              },
              "name": {
                "type": "string"
              },
              "size": {
                "format": "uint64",
                "minimum": 0,
                "type": "integer"
              }
            },
            "required": [
              "is_dir",
              "is_symlink",
              "mode",
              "mtime",
              "name",
              "size"
            ],
            "type": "object"
          }
        },
        "properties": {
          "entries": {
            "items": {
              "$ref": "#/definitions/FsEntry"
            },
            "type": "array"
          }
        },
        "required": [
          "entries"
        ],
        "title": "LsResponse",
        "type": "object"
      }
    },
    {
      "description": "Create a directory inside a sandbox. Pass `parents:true` to make missing parents like `mkdir -p`. Example: { sandbox_id: \"...\", path: \"/home/app/cache\", mode: \"0755\", parents: true }",
      "metadata": {},
      "name": "sandbox::fs::mkdir",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "examples": [
          {
            "mode": "0755",
            "parents": true,
            "path": "/home/app/cache",
            "sandbox_id": "00000000-0000-0000-0000-000000000000"
          }
        ],
        "properties": {
          "mode": {
            "default": "0755",
            "description": "Octal permissions for the new directory (e.g. `\"0755\"`).",
            "type": "string"
          },
          "parents": {
            "default": false,
            "description": "Create intermediate parent directories like `mkdir -p`.",
            "type": "boolean"
          },
          "path": {
            "description": "Absolute path of the directory to create inside the sandbox guest.",
            "type": "string"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          }
        },
        "required": [
          "path",
          "sandbox_id"
        ],
        "title": "MkdirRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "created": {
            "type": "boolean"
          }
        },
        "required": [
          "created"
        ],
        "title": "MkdirResponse",
        "type": "object"
      }
    },
    {
      "description": "Move or rename a path inside a sandbox. Pass `overwrite:true` to clobber an existing destination. Example: { sandbox_id: \"...\", src: \"/home/app/old.js\", dst: \"/home/app/new.js\" }",
      "metadata": {},
      "name": "sandbox::fs::mv",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "examples": [
          {
            "dst": "/home/app/new.js",
            "overwrite": false,
            "sandbox_id": "00000000-0000-0000-0000-000000000000",
            "src": "/home/app/old.js"
          }
        ],
        "properties": {
          "dst": {
            "description": "Destination absolute path inside the sandbox guest.",
            "type": "string"
          },
          "overwrite": {
            "default": false,
            "description": "Allow overwriting `dst` if it already exists.",
            "type": "boolean"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          },
          "src": {
            "description": "Source absolute path inside the sandbox guest.",
            "type": "string"
          }
        },
        "required": [
          "dst",
          "sandbox_id",
          "src"
        ],
        "title": "MvRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "moved": {
            "type": "boolean"
          }
        },
        "required": [
          "moved"
        ],
        "title": "MvResponse",
        "type": "object"
      }
    },
    {
      "description": "Read a file from a sandbox. Always returns `content`: a StreamChannelRef callers can subscribe to for the full file bytes. For UTF-8 text files under 1 MiB, the response also carries an inline `body` string so callers can short-circuit the subscription. Example: { sandbox_id: \"...\", path: \"/home/app/index.js\" }",
      "metadata": {},
      "name": "sandbox::fs::read",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "examples": [
          {
            "path": "/home/app/index.js",
            "sandbox_id": "00000000-0000-0000-0000-000000000000"
          }
        ],
        "properties": {
          "path": {
            "description": "Absolute path to read inside the sandbox guest.",
            "type": "string"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          }
        },
        "required": [
          "path",
          "sandbox_id"
        ],
        "title": "ReadRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "ChannelDirection": {
            "enum": [
              "read",
              "write"
            ],
            "type": "string"
          },
          "StreamChannelRef": {
            "properties": {
              "access_key": {
                "type": "string"
              },
              "channel_id": {
                "type": "string"
              },
              "direction": {
                "$ref": "#/definitions/ChannelDirection"
              }
            },
            "required": [
              "access_key",
              "channel_id",
              "direction"
            ],
            "type": "object"
          }
        },
        "properties": {
          "body": {
            "description": "Inline UTF-8 body. Populated for text files under [`INLINE_BUFFER_CAP`] (1 MiB) that decode cleanly as UTF-8. `None` for large or binary files; subscribe to `content` instead. When `Some`, the same bytes are also delivered through `content` so legacy callers keep working — new callers can use `body` directly and skip the channel subscription.",
            "type": [
              "string",
              "null"
            ]
          },
          "content": {
            "allOf": [
              {
                "$ref": "#/definitions/StreamChannelRef"
              }
            ],
            "description": "Channel ref for the file body. Always set; callers can subscribe to receive the full file contents as bytes. Preserved for wire compatibility with peers that statically type this field as `StreamChannelRef`."
          },
          "mode": {
            "type": "string"
          },
          "mtime": {
            "format": "int64",
            "type": "integer"
          },
          "size": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          }
        },
        "required": [
          "content",
          "mode",
          "mtime",
          "size"
        ],
        "title": "ReadResponse",
        "type": "object"
      }
    },
    {
      "description": "Remove a file or directory inside a sandbox. Pass `recursive:true` to remove directories with contents. Example: { sandbox_id: \"...\", path: \"/home/app/temp\", recursive: true }",
      "metadata": {},
      "name": "sandbox::fs::rm",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "examples": [
          {
            "path": "/home/app/temp",
            "recursive": true,
            "sandbox_id": "00000000-0000-0000-0000-000000000000"
          }
        ],
        "properties": {
          "path": {
            "description": "Absolute path to remove inside the sandbox guest.",
            "type": "string"
          },
          "recursive": {
            "default": false,
            "description": "Remove directories and their contents recursively (like `rm -rf`).",
            "type": "boolean"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          }
        },
        "required": [
          "path",
          "sandbox_id"
        ],
        "title": "RmRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "removed": {
            "type": "boolean"
          }
        },
        "required": [
          "removed"
        ],
        "title": "RmResponse",
        "type": "object"
      }
    },
    {
      "description": "Find-and-replace in files inside a sandbox. Pass either `path` (walked like grep) OR `files` (explicit list), not both. Example: { sandbox_id: \"...\", path: \"/home/app/src\", pattern: \"foo\", replacement: \"bar\" }",
      "metadata": {},
      "name": "sandbox::fs::sed",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Find-and-replace request, accepting either an explicit `files` list or a `path` walked like grep does. Exactly one of those two must be provided — `handle_sed` returns S210 otherwise.",
        "examples": [
          {
            "path": "/home/app/src",
            "pattern": "foo",
            "recursive": true,
            "replacement": "bar",
            "sandbox_id": "00000000-0000-0000-0000-000000000000"
          }
        ],
        "properties": {
          "exclude_glob": {
            "default": [],
            "description": "Gitignore-style exclude filter applied to paths relative to `path`. Only meaningful with `path`.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "files": {
            "default": [],
            "description": "Legacy form: explicit list of paths to rewrite.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "first_only": {
            "default": false,
            "type": "boolean"
          },
          "ignore_case": {
            "default": false,
            "type": "boolean"
          },
          "include_glob": {
            "default": [],
            "description": "Gitignore-style include filter applied to paths relative to `path`. Only meaningful with `path`.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "path": {
            "default": null,
            "description": "New form: walk `path` like grep does. May be a directory or a single file. Mutually exclusive with `files`.",
            "type": [
              "string",
              "null"
            ]
          },
          "pattern": {
            "type": "string"
          },
          "recursive": {
            "default": true,
            "description": "Whether to descend into subdirectories. Only meaningful with `path`. Defaults to `true` so the path-form behaves like grep.",
            "type": "boolean"
          },
          "regex": {
            "default": true,
            "type": "boolean"
          },
          "replacement": {
            "type": "string"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          }
        },
        "required": [
          "pattern",
          "replacement",
          "sandbox_id"
        ],
        "title": "SedRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "FsSedFileResult": {
            "description": "One `sed` file-level outcome. `success=false` carries the human failure message in `error`; otherwise `error` is absent.",
            "properties": {
              "error": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "path": {
                "type": "string"
              },
              "replacements": {
                "format": "uint64",
                "minimum": 0,
                "type": "integer"
              },
              "success": {
                "type": "boolean"
              }
            },
            "required": [
              "path",
              "replacements",
              "success"
            ],
            "type": "object"
          }
        },
        "properties": {
          "results": {
            "items": {
              "$ref": "#/definitions/FsSedFileResult"
            },
            "type": "array"
          },
          "total_replacements": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          }
        },
        "required": [
          "results",
          "total_replacements"
        ],
        "title": "SedResponse",
        "type": "object"
      }
    },
    {
      "description": "Stat a path inside a sandbox. Example: { sandbox_id: \"...\", path: \"/home/app/index.js\" }",
      "metadata": {},
      "name": "sandbox::fs::stat",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "examples": [
          {
            "path": "/home/app/index.js",
            "sandbox_id": "00000000-0000-0000-0000-000000000000"
          }
        ],
        "properties": {
          "path": {
            "description": "Absolute path to inspect inside the sandbox guest.",
            "type": "string"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          }
        },
        "required": [
          "path",
          "sandbox_id"
        ],
        "title": "StatRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "description": "Mirrors `FsEntry`. Note: the shell protocol does not carry uid/gid; those fields are absent at this trigger level.",
        "properties": {
          "is_dir": {
            "type": "boolean"
          },
          "is_symlink": {
            "type": "boolean"
          },
          "mode": {
            "type": "string"
          },
          "mtime": {
            "format": "int64",
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "size": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          }
        },
        "required": [
          "is_dir",
          "is_symlink",
          "mode",
          "mtime",
          "name",
          "size"
        ],
        "title": "StatResponse",
        "type": "object"
      }
    },
    {
      "description": "Write a file into a sandbox. `content` accepts a UTF-8 string (recommended for source/text), a StreamChannelRef object (for large uploads), or use `content_b64` for small binary. Example: { sandbox_id: \"...\", path: \"/home/app/index.js\", content: \"console.log('hi')\\n\" }",
      "metadata": {},
      "name": "sandbox::fs::write",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "ChannelDirection": {
            "enum": [
              "read",
              "write"
            ],
            "type": "string"
          },
          "StreamChannelRef": {
            "properties": {
              "access_key": {
                "type": "string"
              },
              "channel_id": {
                "type": "string"
              },
              "direction": {
                "$ref": "#/definitions/ChannelDirection"
              }
            },
            "required": [
              "access_key",
              "channel_id",
              "direction"
            ],
            "type": "object"
          },
          "WriteContent": {
            "anyOf": [
              {
                "description": "Inline UTF-8 string. Written to the file verbatim.",
                "type": "string"
              },
              {
                "allOf": [
                  {
                    "$ref": "#/definitions/StreamChannelRef"
                  }
                ],
                "description": "Streaming channel for large / binary uploads."
              }
            ],
            "description": "File body for `sandbox::fs::write`. Untagged so the JSON shape decides which variant runs:\n\n- A bare JSON string (`\"console.log('hi')\"`) → [`WriteContent::Utf8`]. This is what LLM agents naturally pass and the recommended form for source files / configs / small text. - An object that matches [`StreamChannelRef`] → [`WriteContent::Stream`]. The existing channel-streaming path for large or binary payloads coming from a programmatic caller that can construct a channel.\n\nSerde tries variants in declaration order; `Utf8` matches first because every JSON string deserialises into `String`, and `StreamChannelRef` requires an object. Binary inline data uses the separate `content_b64` field on [`WriteRequest`] (not a variant here, so a caller can't accidentally pass base64 expecting it to be decoded — they have to opt in by name)."
          }
        },
        "examples": [
          {
            "content": "console.log('hello world')\n",
            "mode": "0644",
            "parents": true,
            "path": "/home/app/index.js",
            "sandbox_id": "00000000-0000-0000-0000-000000000000"
          }
        ],
        "properties": {
          "content": {
            "anyOf": [
              {
                "$ref": "#/definitions/WriteContent"
              },
              {
                "type": "null"
              }
            ],
            "description": "File body. Pass a UTF-8 string for source/text (the agent-natural form), or a `StreamChannelRef` object for streaming large/binary uploads. Mutually exclusive with `content_b64`; exactly one of the two must be set."
          },
          "content_b64": {
            "default": null,
            "description": "Base64-encoded inline body for small binary payloads. Mutually exclusive with `content`.",
            "type": [
              "string",
              "null"
            ]
          },
          "mode": {
            "default": "0644",
            "description": "Octal permissions for the new file (default `\"0644\"`).",
            "type": "string"
          },
          "parents": {
            "default": false,
            "description": "Create missing parent directories before writing.",
            "type": "boolean"
          },
          "path": {
            "description": "Absolute destination path inside the sandbox guest.",
            "type": "string"
          },
          "sandbox_id": {
            "description": "UUID returned by `sandbox::create`.",
            "type": "string"
          }
        },
        "required": [
          "path",
          "sandbox_id"
        ],
        "title": "WriteRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "bytes_written": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "path": {
            "type": "string"
          }
        },
        "required": [
          "bytes_written",
          "path"
        ],
        "title": "WriteResponse",
        "type": "object"
      }
    },
    {
      "description": "List active sandboxes",
      "metadata": {},
      "name": "sandbox::list",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "title": "ListRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "SandboxSummary": {
            "properties": {
              "age_secs": {
                "format": "uint64",
                "minimum": 0,
                "type": "integer"
              },
              "exec_in_progress": {
                "type": "boolean"
              },
              "image": {
                "type": "string"
              },
              "name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "sandbox_id": {
                "type": "string"
              },
              "stopped": {
                "type": "boolean"
              }
            },
            "required": [
              "age_secs",
              "exec_in_progress",
              "image",
              "sandbox_id",
              "stopped"
            ],
            "type": "object"
          }
        },
        "properties": {
          "sandboxes": {
            "items": {
              "$ref": "#/definitions/SandboxSummary"
            },
            "type": "array"
          }
        },
        "required": [
          "sandboxes"
        ],
        "title": "ListResponse",
        "type": "object"
      }
    },
    {
      "description": "Run code in an ephemeral sandbox in ONE call. Composes create + fs::write + exec + stop. `lang` selects the interpreter (`node`, `python`, `shell`, or a custom binary path). Sandbox auto-stops on success and on failure unless `keep_sandbox: true`. Example: { image: \"node\", code: \"console.log('hi')\", lang: \"node\" }",
      "metadata": {},
      "name": "sandbox::run",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "definitions": {
          "EnvShape": {
            "anyOf": [
              {
                "description": "Original wire shape: `[\"FOO=bar\", \"PATH=/usr/bin\"]`.",
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "additionalProperties": {
                  "type": "string"
                },
                "description": "Agent-natural shape: `{ \"FOO\": \"bar\", \"PATH\": \"/usr/bin\" }`. Iteration is sorted by key (BTreeMap) so two callers passing the same map get the same env-var ordering.",
                "type": "object"
              }
            ],
            "description": "Environment-variable input. Agents naturally pass `{ FOO: \"bar\" }` (matching Docker/npm/k8s mental models); the original wire shape was `Vec<\"K=V\">`. The untagged enum accepts both; `into_kv_vec()` normalises to the canonical `Vec<String>` the runner expects.\n\n`Default` is the empty vec form (the historical wire shape)."
          },
          "RunFile": {
            "description": "Optional sibling file to drop into the sandbox before the main code runs.",
            "properties": {
              "content": {
                "description": "UTF-8 file body. Streaming and base64 are not supported here; callers needing those should use `sandbox::create` + repeated `sandbox::fs::write` calls instead.",
                "type": "string"
              },
              "path": {
                "description": "Absolute path inside the sandbox guest where the file lands.",
                "type": "string"
              }
            },
            "required": [
              "content",
              "path"
            ],
            "type": "object"
          }
        },
        "examples": [
          {
            "code": "console.log('hello world')",
            "image": "node",
            "lang": "node",
            "timeout_ms": 300000
          }
        ],
        "properties": {
          "code": {
            "description": "The actual code to run. Written to a `/tmp/run.{ext}` file inside the sandbox; the chosen interpreter runs that file.",
            "type": "string"
          },
          "env": {
            "allOf": [
              {
                "$ref": "#/definitions/EnvShape"
              }
            ],
            "description": "Env vars exposed to the interpreter. Accepts both `Vec<\"K=V\">` and `{ K: V }` map shapes (same as `sandbox::exec.env`)."
          },
          "files": {
            "description": "Optional sibling files (extra source modules, config, fixtures).",
            "items": {
              "$ref": "#/definitions/RunFile"
            },
            "type": "array"
          },
          "image": {
            "description": "Catalog name of the image to boot (preset or `custom_images` key). Same value space as `sandbox::create`.",
            "type": "string"
          },
          "keep_sandbox": {
            "default": false,
            "description": "If true, the sandbox is NOT stopped after the run completes; `sandbox_id` is returned so the caller can poke around. Default false (the sandbox is torn down on either success or failure).",
            "type": "boolean"
          },
          "lang": {
            "description": "Selects the interpreter and file extension. Required. Built-in values: `\"node\"`, `\"python\"`, `\"shell\"`. Any other string is treated as a literal interpreter binary path inside the VM and the file is written to `/tmp/run.txt`. There is no default — there's no honest universal answer to \"what language is this code\", and silently defaulting to shell makes Python or JS code produce confusing line-by-line failures.",
            "type": "string"
          },
          "stdin": {
            "default": null,
            "description": "Base64-encoded bytes piped to the interpreter's stdin.",
            "type": [
              "string",
              "null"
            ]
          },
          "timeout_ms": {
            "default": null,
            "description": "Kill-after window for the interpreter, in ms. Defaults to 300_000 (5 minutes); pass a smaller value to fail fast on quick probes.",
            "format": "uint64",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          }
        },
        "required": [
          "code",
          "image",
          "lang"
        ],
        "title": "RunRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "duration_ms": {
            "format": "uint64",
            "minimum": 0,
            "type": "integer"
          },
          "exit_code": {
            "format": "int32",
            "type": [
              "integer",
              "null"
            ]
          },
          "sandbox_id": {
            "description": "Present only if `keep_sandbox: true` was set on the request. Otherwise null (sandbox auto-stopped).",
            "type": [
              "string",
              "null"
            ]
          },
          "stderr": {
            "type": "string"
          },
          "stdout": {
            "type": "string"
          },
          "success": {
            "type": "boolean"
          },
          "timed_out": {
            "type": "boolean"
          }
        },
        "required": [
          "duration_ms",
          "stderr",
          "stdout",
          "success",
          "timed_out"
        ],
        "title": "RunResponse",
        "type": "object"
      }
    },
    {
      "description": "Stop and remove a running sandbox. Set `wait: true` to block until the VM process exits and resources are reclaimed.",
      "metadata": {},
      "name": "sandbox::stop",
      "request_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "sandbox_id": {
            "type": "string"
          },
          "wait": {
            "default": false,
            "description": "Block until the VM is fully reaped before returning.",
            "type": "boolean"
          }
        },
        "required": [
          "sandbox_id"
        ],
        "title": "StopRequest",
        "type": "object"
      },
      "response_schema": {
        "$schema": "http://json-schema.org/draft-07/schema#",
        "properties": {
          "sandbox_id": {
            "type": "string"
          },
          "stopped": {
            "type": "boolean"
          }
        },
        "required": [
          "sandbox_id",
          "stopped"
        ],
        "title": "StopResponse",
        "type": "object"
      }
    }
  ],
  "triggers": []
}
```
