# Sandboxes (/docs/api/gateway/sandboxes)

Isolated gVisor workspaces where agents run Python, bash and LibreOffice and hand back files as scanned, signed artifacts.

A sandbox is an isolated container with a persistent `/workspace` directory. Your code or your agent runs commands in it (Python 3, bash, LibreOffice, Node 24, the usual data and document libraries, fonts with full Romanian coverage), writes results to `/workspace/out` and gets them back as artifacts: scanned, hashed and downloadable through a signed link. The workspace survives between execs until the sandbox is destroyed or expires.

Two backends share this API. **`v2` is the default** since 18 October 2026: a long-lived pod with memory snapshots (pause/resume, fork, a persistent Python kernel, preview URLs), see [Sandboxes v2](#sandboxes-v2). `v1` (Cloud Run, the workspace restored per exec, plus `POST /snapshot`) stays available with `backend: "v1"`. When you omit `backend` and v2 is not available for your key, the gateway falls back to v1; the `backend` field on every sandbox object says which one you got.

Every exec is metered; exec time is billed from the wallet afterwards (see [Billing](#billing)), so responses carry `x-codai-cost-micro-usd: 0`. Every route needs a `codai_` key that reaches tool level `l3_sandbox`.

## Security model [#security-model]

- **gVisor isolation.** Every sandbox runs under gVisor with its own kernel boundary: v2 as a dedicated pod on GKE, v1 on Cloud Run (gen2). The image filesystem is read-only for your code and only `/workspace` is writable.
- **No network by default.** With `egress: { mode: 'none' }` (the default) there is no route out at all: public IPs, private ranges and the metadata server are unreachable.
- **Egress through an allowlisting proxy.** `{ mode: 'allowlist', domains: [...] }` (at most 20 host names, no IP literals, no internal suffixes) or `{ mode: 'preset', preset: 'pypi' | 'npm' }` lets HTTPS reach only those hosts, through the codai egress proxy, which resolves DNS itself and logs every connection. This works on both backends; the proxy opens plain tunnels and never injects credentials.
- **No secrets inside.** No codai key, cloud credential or token is present in the sandbox; environment variables named `CODAI_*` or `GOOGLE_*` are rejected.
- **Audited.** Every create, exec, file transfer, artifact, snapshot and destroy is written to an append-only audit log; each exec returns a reproducibility receipt.
- Data stays in `europe-west1` (`x-codai-data-region`).

## Limits [#limits]

LibreOffice converts every office format in the sandbox, for example `soffice --headless --convert-to pdf --outdir /workspace/out report.docx` (also `.xlsx`, `.pptx`, `.odt`, HTML and CSV). Every command runs under a small init that reaps background helper processes, and `exit_code` is the real exit status of your command.

| Limit                            | Default                             | Error                                    |
| -------------------------------- | ----------------------------------- | ---------------------------------------- |
| Running sandboxes per key        | 3                                   | 429 `sandbox_concurrency_limit`          |
| Running v2 sandboxes per user    | 5 (all keys together)               | 429 `sandbox_concurrency_limit`          |
| Paused v2 sandboxes per user     | 10                                  | 429 `sandbox_paused_limit` (on pause)    |
| v2 platform capacity             | shared                              | 503 `sandbox_capacity` (retry later)     |
| Concurrent execs per sandbox     | 1                                   | 409 `sandbox_busy`                       |
| Lifetime (`ttl_seconds`)         | 1 h default, 24 h max               | 400 `invalid_ttl`                        |
| Exec timeout (`timeout_seconds`) | 900 s                               | 400                                      |
| Workspace size                   | 2 GB                                | exec `error` event `workspace_too_large` |
| Exec seconds per user per day    | 7 200                               | 429 `sandbox_daily_limit`                |
| File upload                      | 100 MB                              | 413                                      |
| Egress allowlist                 | 20 domains                          | 400                                      |
| stdout / stderr per exec         | 1 MB each, then a `truncated` event | —                                        |

## Artifacts [#artifacts]

`GET /v1/sandboxes/{id}/artifacts` collects every file under `/workspace/out`. Each artifact carries:

- `mime`, sniffed from the content (not from the file name);
- `sha256` of the bytes;
- `scan`: the ClamAV verdict, `clean`, `infected` or `error`;
- `url`: a signed download link valid for 1 h, present only when `scan` is `clean` (`expires_at` says when it stops working).

`POST /v1/sandboxes/run` returns the artifacts directly in its result.

## Receipts [#receipts]

Every finished exec (the blocking result and the final `exit` stream event) includes a `receipt`: the image digest, the template, the SHA-256 of the inputs and of the command, every output file with its SHA-256 and size, and the workspace generation before and after. Store it next to the document you produced to prove what code made it, from which inputs.

## Streaming [#streaming]

Send `Accept: text/event-stream` to `POST /v1/sandboxes/{id}/exec` to receive `data:` frames as the command runs: `start`, then any number of `stdout` / `stderr` chunks (and `truncated` when a stream hits 1 MB), then exactly one `exit` (or `error`). Without that header the call blocks and returns one JSON result.

`Idempotency-Key` on exec and run replays the stored response of an earlier request with the same key for 24 h instead of running the command again.

## Quickstart: a PDF with Romanian diacritics [#quickstart-a-pdf-with-romanian-diacritics]

One shot with `POST /v1/sandboxes/run`: the sandbox is created, the script runs, `/workspace/out` is collected and the sandbox is destroyed.

```bash
curl https://ai.codai.ro/v1/sandboxes/run \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: act-2026-10-16-001" \
  -d @- <<'JSON'
{
  "language": "python",
  "timeout_seconds": 120,
  "code": "import os\nfrom reportlab.lib.pagesizes import A4\nfrom reportlab.pdfbase import pdfmetrics\nfrom reportlab.pdfbase.ttfonts import TTFont\nfrom reportlab.pdfgen import canvas\n\npdfmetrics.registerFont(TTFont('DejaVu', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'))\nos.makedirs('/workspace/out', exist_ok=True)\nc = canvas.Canvas('/workspace/out/act.pdf', pagesize=A4)\nc.setFont('DejaVu', 14)\nc.drawString(72, 770, 'Act adițional nr. 1 la contractul de prestări servicii')\nc.drawString(72, 745, 'Părțile convin: ședința se mută în București, str. Ștefan cel Mare.')\nc.save()\nprint('ok')\n"
}
JSON
```

The answer is an exec result whose `artifacts[0].url` downloads `act.pdf` for one hour. The DejaVu Sans font embeds the full `ă â î ș ț` set, so the comma-below `ș` and `ț` render correctly.

## SDKs [#sdks]

```ts
import { Codai } from 'codai-sdk';

const client = new Codai({ apiKey: process.env.CODAI_API_KEY! });
const sb = await client.sandboxes.create({ ttl_seconds: 1800 });
await client.sandboxes.files.put(sb.id, 'in/date.csv', 'client,suma\nSC Exemplu SRL,1200\n');
for await (const ev of client.sandboxes.execStream(sb.id, {
  shell: 'python3 -c "import pandas as pd; print(pd.read_csv(\'in/date.csv\'))"',
})) {
  if (ev.type === 'stdout') process.stdout.write(ev.data);
  if (ev.type === 'exit') console.log('exit', ev.exit_code, ev.receipt.command_sha256);
}
const { data } = await client.sandboxes.artifacts(sb.id);
await client.sandboxes.delete(sb.id);
```

```python
from codai import Codai

client = Codai(api_key="...")
sb = client.sandboxes.create(ttl_seconds=1800)
client.sandboxes.files.put(sb["id"], "in/date.csv", "client,suma\nSC Exemplu SRL,1200\n")
for ev in client.sandboxes.exec_stream(sb["id"], language="python", code="print(open('in/date.csv').read())"):
    if ev["type"] == "stdout":
        print(ev["data"], end="")
result = client.sandboxes.run(shell="weasyprint in/page.html out/page.pdf",
                              inputs=[{"path": "page.html", "url": "https://example.com/page.html"}])
client.sandboxes.delete(sb["id"])
```

## The `codai_sandbox` chat tool [#the-codai_sandbox-chat-tool]

Add `{ "type": "codai_sandbox" }` to `tools` in `POST /v1/chat/completions` (optionally with `egress` and `template`) and the gateway does the rest server-side: it gives the model `sandbox_bash`, `sandbox_python`, `sandbox_edit` and `sandbox_files`, runs them in one sandbox per `x-codai-session-id` (lifetime 1 h, extended by every use) and, at the end, appends the artifacts to the final message as `codai_sandbox.artifacts` plus a markdown list with signed links. The key still needs tool level `l3_sandbox`.

```bash
curl https://ai.codai.ro/v1/chat/completions \
  -H "Authorization: Bearer $CODAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "x-codai-session-id: acte-firma-42" \
  -d '{"model":"codai","tools":[{"type":"codai_sandbox"}],
       "messages":[{"role":"user","content":"Fă-mi un PDF cu o hotărâre AGA pentru SC Exemplu SRL."}]}'
```

The built-in `exec_python` tool now runs on the same sandbox backend; it no longer executes code inside the gateway process.

## Document skills (Claude Skills compatible) [#document-skills-claude-skills-compatible]

Anthropic's pre-built Agent Skills (`docx`, `xlsx`, `pdf`, `pptx`) need a first-party container that is not available on the clouds behind `codai`, so codai provides them on its own sandbox, for every model. Two equivalent forms:

```json
{ "tools": [{ "type": "codai_sandbox", "skills": ["docx", "pdf"] }] }
```

```json
{ "container": { "skills": [{ "type": "anthropic", "skill_id": "xlsx", "version": "latest" }] } }
```

Both work on `/v1/chat/completions` and `/v1/messages`. They enable the `codai_sandbox` tool and add a fixed guidance block for the requested skills (python-docx, openpyxl, reportlab/pypdf, python-pptx, weasyprint), so the model produces real files in `/workspace/out` and you get them as signed links. Custom skills (`type: "custom"`) and container reuse (`container.id`) answer 400; use `x-codai-session-id` to keep one sandbox per conversation.

## MCP tools [#mcp-tools]

The codai MCP server (`https://ai.codai.ro/mcp`) exposes `sandbox_create`, `sandbox_list`, `sandbox_exec`, `sandbox_files` (`list` / `read` / `write` / `artifacts`) and `sandbox_kill`, plus the v2 tools `sandbox_run_code`, `sandbox_pause`, `sandbox_resume`, `sandbox_fork` and `sandbox_preview` (see [Sandboxes v2](#sandboxes-v2)). They call the same routes with your key, so the tool level, the limits and the metering are the same as on the REST API. `sandbox_files` reads return at most 256 KB inline; take bigger outputs through `op: "artifacts"` (signed URL + SHA-256).

## Billing [#billing]

Since 17 October 2026 (00:00 UTC) exec time is charged from the wallet at **€0.10 per exec-hour**, with the **first 10 exec-hours per user per UTC month free**; usage before that date is never billed. Idle running sandboxes cost nothing. Usage is settled in 10-minute buckets (`wallet_ledger` source `sandbox_charge`). If a bucket cannot be paid, new creates and execs answer `402 insufficient_balance` (with `unpaid_micro_eur`) until a top-up settles it.

This section prices the `v1` backend. Sandboxes v2 (the default) are billed differently, see [v2 billing](#v2-billing).

## Sandboxes v2 [#sandboxes-v2]

Generally available to every key since 18 October 2026 and the **default backend** when `backend` is omitted. The REST surface above is shared: a sandbox picks its backend once, at create, and `backend` (`v1` or `v2`) comes back on every sandbox object. If v2 is not available for your key and you did not ask for it explicitly, the create falls back to v1; with an explicit `backend: "v2"` it fails instead.

A v2 sandbox is a long-lived, isolated machine instead of a workspace restored per exec: processes keep running between calls, and the whole state (memory, running processes, `/workspace`) can be snapshotted, paused, resumed and forked. On v2:

- **Egress** works as on v1: `none` (default), a `pypi` / `npm` preset or an allowlist of up to 20 domains, through the codai egress proxy (plain tunnels, no credential injection). Forks inherit the parent's egress.
- **Toolchain:** Python 3, bash, the document libraries and **Node 24 with npm** in every template.
- **`inputs`** on exec and **`GET /artifacts`** (ClamAV-scanned, SHA-256, signed URLs) behave exactly as on v1.
- **v1 only:** `POST /v1/sandboxes/{id}/snapshot` answers `409 not_supported_on_v2`; use pause/fork instead.

### v2 limits [#v2-limits]

| Limit                         | Value                   | Error                               |
| ----------------------------- | ----------------------- | ----------------------------------- |
| Running sandboxes per key     | 3                       | 429 `sandbox_concurrency_limit`     |
| Running v2 sandboxes per user | 5, across all your keys | 429 `sandbox_concurrency_limit`     |
| Paused v2 sandboxes per user  | 10                      | 429 `sandbox_paused_limit` on pause |
| Fork children per call        | 5                       | 400 `invalid_count`                 |
| Platform capacity             | shared pool             | 503 `sandbox_capacity`, retry later |

`503 sandbox_capacity` is transient: retry with backoff, or create with `backend: "v1"`.

### Templates [#templates]

| `template`    | What is inside                                                                     |
| ------------- | ---------------------------------------------------------------------------------- |
| `codai-base`  | The default image: Python 3, bash, Node 24 + npm and the document libraries.       |
| `brivio-acte` | `codai-base` plus LibreOffice, prepared for office documents and started warm.     |
| `desktop`     | A Linux desktop reachable through noVNC on port 6080 (open it with a preview URL). |

When a template has a warm snapshot, the create answer carries `restored: true`.

### Persistent kernel: `run_code` [#persistent-kernel-run_code]

`POST /v1/sandboxes/{id}/run_code` with `{ "code": "...", "timeout_seconds": 60 }` runs a Python cell in one long-lived kernel. Globals survive between calls, across pause/resume and into forks. The answer is `{ stdout, stderr, result, error, duration_ms, execution_count, kernel_restarted }`: `result` is the repr of the last expression (at most 64 KB), `error` is `{ name, value, traceback }` for an uncaught exception. A cell past its timeout restarts the kernel (`kernel_restarted: true`, state lost). `POST /v1/sandboxes/{id}/kernel/reset` restarts it on purpose; files are kept.

### Pause and resume [#pause-and-resume]

`POST /v1/sandboxes/{id}/pause` snapshots the sandbox and stops it; its status becomes `paused`. You can keep at most 10 paused v2 sandboxes (`429 sandbox_paused_limit`); delete old ones to free a place. Resuming counts against the running caps again. `POST /v1/sandboxes/{id}/resume` brings it back and answers the sandbox plus `restored` and `ready_ms`. `restored: true` means memory and processes came back exactly as they were; `restored: false` means a cold start from the saved workspace (files intact, kernel state lost). codai measures this inside the sandbox; it is never assumed.

### Fork [#fork]

`POST /v1/sandboxes/{id}/fork` with `{ "count": 1..5, "ttl_seconds"?: n }` snapshots a running sandbox (it keeps running) and starts `count` copies from that snapshot. Each copy is a full sandbox with its own id, workspace and kernel state, and is billed like any other. The answer is `{ object: "list", parent_id, data: [{ id, restored, ready_ms, error? }] }`: per copy, `restored: false` means it started cold and `error` means it failed and is not running. A fork may not take a key above 6 live sandboxes, nor you above 5 running v2 sandboxes (`429 sandbox_concurrency_limit`).

### Preview URLs [#preview-urls]

`POST /v1/sandboxes/{id}/preview` with `{ "port": 3000, "ttl_seconds": 900 }` answers `{ port, url, expires_at }`: a signed HTTPS URL on `*.sbx.codai.ro` that forwards to that port inside the sandbox (a dev server, or noVNC on 6080 in `desktop`). Ports 1024–65535 except 8080; validity 60 s to 1 h (default 15 min). The URL needs no key: anyone holding it reaches the port until it expires, so share it only with the people who should see the app.

```ts
const sb = await client.sandboxes.create({
  template: 'codai-base',
  egress: { mode: 'preset', preset: 'pypi' },
}); // v2 by default
await client.sandboxes.runCode(sb.id, {
  code: 'import pandas as pd\ndf = pd.DataFrame({"a": [1, 2]})',
});
const r = await client.sandboxes.runCode(sb.id, { code: 'df["a"].sum()' }); // r.result === '3'
const { data } = await client.sandboxes.fork(sb.id, { count: 3 });
await client.sandboxes.pause(sb.id);
const back = await client.sandboxes.resume(sb.id); // back.restored, back.ready_ms
```

```python
sb = client.sandboxes.create(backend="v2", template="desktop")
p = client.sandboxes.preview(sb["id"], 6080, ttl_seconds=600)
print(p["url"], p["expires_at"])
```

### v2 billing [#v2-billing]

- **Running:** €0.10 per wall-clock hour while the sandbox exists (busy or idle), with **5 free hours per user per UTC month** (separate from the v1 exec-hours above).
- **Paused:** only the snapshot is stored, at **€0.05 per GB-month**, metered hourly.
- Charges are settled from the wallet like v1 (`402 insufficient_balance` when a bucket cannot be paid).

<OpenAPIPage
  document="./openapi/en/gateway.yaml"
  operations={[
    { path: '/v1/sandboxes', method: 'post' },
    { path: '/v1/sandboxes', method: 'get' },
    { path: '/v1/sandboxes/{id}', method: 'get' },
    { path: '/v1/sandboxes/{id}/exec', method: 'post' },
    { path: '/v1/sandboxes/{id}/files/{path}', method: 'put' },
    { path: '/v1/sandboxes/{id}/files/{path}', method: 'get' },
    { path: '/v1/sandboxes/{id}/files', method: 'get' },
    { path: '/v1/sandboxes/{id}/artifacts', method: 'get' },
    { path: '/v1/sandboxes/{id}/snapshot', method: 'post' },
    { path: '/v1/sandboxes/{id}', method: 'delete' },
    { path: '/v1/sandboxes/run', method: 'post' },
    { path: '/v1/sandboxes/{id}/run_code', method: 'post' },
    { path: '/v1/sandboxes/{id}/kernel/reset', method: 'post' },
    { path: '/v1/sandboxes/{id}/pause', method: 'post' },
    { path: '/v1/sandboxes/{id}/resume', method: 'post' },
    { path: '/v1/sandboxes/{id}/fork', method: 'post' },
    { path: '/v1/sandboxes/{id}/preview', method: 'post' },
  ]}
/>
