# Tilion for AI agents

Tilion gives an agent its own computer: a Linux microVM with a shell, a filesystem, a real
Chromium, and network access, isolated in hardware from every other sandbox. It is meant to be
kept. Files, installed packages, and running processes stay between calls, across idle periods,
and across days, so an agent can work the way a person does at a workstation instead of
starting from nothing at every step.

This page is for people building agents and for agents reading it directly. The whole of the
documentation is also at [`/llms-full.txt`](https://sandbox.tilion.dev/llms-full.txt) as one Markdown file, and
an index at [`/llms.txt`](https://sandbox.tilion.dev/llms.txt).

## The model in one paragraph

You create a sandbox once per task, conversation, or end user, and keep its id (or find it
again by labels). Every call (run a command, read a file, open a port) goes to that sandbox. When
nothing has touched it for `idle_pause_after_s` seconds (600 by default) it pauses: the whole
machine, memory included, is saved, and it stops counting against your running quotas. The next
call resumes it where it was, running processes included, in well under a second. Paused
sandboxes are kept in object storage, so they survive the loss of the machine they ran on. You
delete a sandbox when the work is finished.

## Connect

Every request carries your API key:

```sh
export TILION_API_KEY=tsk_...                 # from sign-up, or the dashboard
export TILION_BASE_URL=https://sandbox.tilion.dev
```

Install the SDKs from this service (they are not on public package indexes yet):

```sh
pip install https://sandbox.tilion.dev/downloads/tilion_sandbox-0.2.0-py3-none-any.whl
npm install https://sandbox.tilion.dev/downloads/tilion-sandbox-0.2.0.tgz
```

There are four ways to hand Tilion to an agent. Pick the one that matches how the agent runs.

| Your agent runs in | Use |
|---|---|
| Claude Code, Cursor, Claude Desktop, or any MCP client | the MCP server (below) |
| The OpenAI Agents SDK | the `tilion-openai-agents` sandbox backend |
| Your own loop on the Anthropic or OpenAI API | the tool definitions on this page, backed by the Python or TypeScript SDK |
| Anything that can make HTTP requests | the REST API ([reference](api.md)) |

## MCP server

The MCP server exposes one persistent sandbox to the agent as tools. Without a `sandbox_id` the
tools use the session's own sandbox: the one labelled `mcp_session=<TILION_MCP_SESSION>`,
created on first use from the `browser` template and found again afterwards. The same session
name gets the same machine back after the agent restarts.

```sh
curl -O https://sandbox.tilion.dev/downloads/tilion_mcp.py
pip install "mcp>=2" https://sandbox.tilion.dev/downloads/tilion_sandbox-0.2.0-py3-none-any.whl
```

Claude Code:

```sh
claude mcp add tilion --env TILION_API_KEY=$TILION_API_KEY --env TILION_BASE_URL=https://sandbox.tilion.dev \
    --env TILION_MCP_SESSION=my-project -- python3 /path/to/tilion_mcp.py
```

Cursor and Claude Desktop (`mcp.json` / `claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "tilion": {
      "command": "python3",
      "args": ["/path/to/tilion_mcp.py"],
      "env": {
        "TILION_API_KEY": "tsk_...",
        "TILION_BASE_URL": "https://sandbox.tilion.dev",
        "TILION_MCP_SESSION": "my-project"
      }
    }
  }
}
```

| Tool | What it does |
|---|---|
| `sandbox_exec` | Run a shell command (`bash -lc`); `background=true` starts a long-running process and returns its id |
| `sandbox_read_file` | Read a file as text |
| `sandbox_write_file` | Write a text file, creating directories |
| `sandbox_list_files` | List a directory (default `/workspace`) |
| `sandbox_expose_port` | A URL for a port in the sandbox |
| `browser_screenshot` | Open a URL in the sandbox's Chromium and return a screenshot; `http://localhost:PORT` shows what the sandbox itself serves |
| `sandbox_info` | The sandbox's id, state, size, and labels |
| `sandbox_pause` | Pause now (it resumes on the next tool call) |

`TILION_MCP_TEMPLATE` picks another template for the session's sandbox.

## OpenAI Agents SDK

```python
from agents.sandbox import SandboxAgent, SandboxRunConfig
from tilion_openai_agents import TilionSandboxClient, TilionSandboxClientOptions

run_config = SandboxRunConfig(client=TilionSandboxClient(), options=TilionSandboxClientOptions())
```

`resume()` reattaches to the same sandbox, paused or running, with files and background
processes intact. Sandboxes are not deleted when the run ends (`delete_on_exit=False`), so a
session can continue days later.

## Your own agent loop

Give the model a small set of tools and implement each with one SDK call. These definitions
work with the Anthropic Messages API (`input_schema`) and, renamed to `parameters`, with OpenAI
function calling.

```python
TOOLS = [
    {"name": "run",
     "description": "Run a shell command on your Linux computer (bash, Ubuntu 24.04, sudo without a "
                    "password). Files and processes persist between calls. Returns exit code, stdout, stderr.",
     "input_schema": {"type": "object", "properties": {
         "command": {"type": "string"},
         "cwd": {"type": "string", "description": "Directory, default /workspace"},
         "timeout_s": {"type": "number", "description": "Kill after this many seconds, default 300"},
         "background": {"type": "boolean", "description": "Start and return at once (servers, watchers)"}},
         "required": ["command"]}},
    {"name": "read_file",
     "description": "Read a text file from your computer.",
     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
    {"name": "write_file",
     "description": "Create or replace a text file on your computer.",
     "input_schema": {"type": "object", "properties": {
         "path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
    {"name": "open_port",
     "description": "Get a URL for a web server you started on your computer, to give to the user.",
     "input_schema": {"type": "object", "properties": {"port": {"type": "integer"}}, "required": ["port"]}},
]
```

A complete loop with the Anthropic Python SDK:

```python
import anthropic
from tilion_sandbox import Sandbox

def clip(text, limit=20000):
    return text if len(text) <= limit else text[:limit // 2] + "\n...[truncated]...\n" + text[-limit // 2:]

def run_tool(sbx, name, args):
    if name == "run":
        if args.get("background"):
            p = sbx.exec(args["command"], cwd=args.get("cwd"), background=True)
            return f"started process {p.id}"
        r = sbx.exec(args["command"], cwd=args.get("cwd"), timeout=args.get("timeout_s", 300),
                     max_output_bytes=20000)
        return f"exit {r.exit_code}\nstdout:\n{r.stdout}\nstderr:\n{r.stderr}"
    if name == "read_file":
        return clip(sbx.files.read_text(args["path"]))
    if name == "write_file":
        sbx.files.write(args["path"], args["content"])
        return "written"
    if name == "open_port":
        return sbx.expose_port(args["port"])
    return f"unknown tool {name}"

def chat(user_id: str, prompt: str):
    # One computer per user, found again by label on every turn.
    sbx, _ = Sandbox.get_or_create(labels={"user": user_id})
    client = anthropic.Anthropic()
    messages = [{"role": "user", "content": prompt}]
    while True:
        reply = client.messages.create(model="claude-opus-5-5", max_tokens=4096, tools=TOOLS,
                                       messages=messages)
        messages.append({"role": "assistant", "content": reply.content})
        calls = [b for b in reply.content if b.type == "tool_use"]
        if not calls:
            return reply
        messages.append({"role": "user", "content": [
            {"type": "tool_result", "tool_use_id": c.id, "content": run_tool(sbx, c.name, c.input)}
            for c in calls]})
```

Errors from the SDK are `TilionError` with `.status` and `.code`; return the message to the
model as the tool result so it can react (a missing file, a command that is not installed).

## Patterns that work

**One sandbox per unit of work.** A conversation, a user, a repository, or a task. Label it and
find it again with `Sandbox.get_or_create(labels={...})` instead of storing ids. Pass an
`idempotency_key` to `create` when a retry must not make a second sandbox.

**Let pause happen.** Do not delete sandboxes between turns. An idle sandbox pauses by itself
and costs no running quota; the next call resumes it. Set `idle_pause_after_s` lower for
bursty, many-user agents, higher for long jobs that must not pause mid-run (a running command
keeps the sandbox awake while it runs). `sbx.pause(durable=True)` returns once the state is in
object storage.

**Checkpoints for long jobs.** Running sandboxes are checkpointed every few minutes, so even a
host failure loses at most the last few minutes. `POST /v1/sandboxes/{id}/checkpoint` takes one
now (before a risky step, for example).

**Fork to try several paths at once.** `sbx.fork(4)` makes four copies of the sandbox as it is
now: files, installed packages, memory, and running processes. Each copy is a sandbox of its own
that can go a different way, while the original keeps running. Copies keep the original's size,
template, network policy, and environment, get their own hostname and machine id, and count
toward your quotas. The original is frozen only briefly while its state is copied. Use it to try
several fixes or plans from one prepared starting point, then keep the copy that worked and
delete the rest.

```python
base = Sandbox.create()
base.exec("git clone https://github.com/example/app /workspace/app && cd /workspace/app && npm ci")
attempts = base.fork(3, labels={"task": "fix-bug-123"})
results = [a.exec(f"cd /workspace/app && git apply /tmp/patch{i}.diff && npm test") for i, a in enumerate(attempts)]
```

**Long-running processes.** Start servers and watchers with `background=True`; they keep
running across calls and across pause and resume. `sbx.processes()` lists them, and a
`Process` gives `.wait()`, `.output()`, `.send_stdin()`, and `.kill()`. `ensure_process(command,
marker=...)` starts a process only if it is not already running, which makes agent steps safe to
repeat.

**Size for the job.** Ask for vCPUs, memory, and disk when creating (see [sizes](sizes.md)).
The first sandbox of a size not used before waits once while it is prepared; common sizes never
wait.

**Keep outputs small.** Pass `max_output_bytes` to `exec` (for example 20000): longer output
comes back as its first and last parts with the middle dropped, `truncated` set, and the full
sizes in `stdout_bytes` and `stderr_bytes`. The command still runs to the end. Write big results
to files the agent can page through.

## Web servers and ports

A program in the sandbox that listens on a port, on `127.0.0.1` or on all addresses, is
reachable at:

```
https://sandbox.tilion.dev/v1/sandboxes/<id>/ports/<port>/<path>
```

`sbx.expose_port(3000)` returns that URL. It needs the API key: an `Authorization: Bearer`
header, or `?token=<key>` for a browser. `expose_port(3000, public=True)` makes it open to anyone
with the URL, for showing a user a preview. HTTP (streamed, so server-sent events and large
downloads work) and WebSockets both pass through. A request to a paused sandbox's port resumes
it first.

Apps are served under that path, so links an app writes as absolute paths (`/static/app.js`)
point outside it. Most dev servers have a base-path setting (Vite `--base`, Next.js `basePath`)
for this.

## The browser

The `browser` template has Chromium already running when the sandbox starts. Drive it from your
code over the Chrome DevTools Protocol:

```python
info = sbx.start_browser()                 # {"cdp_url": "wss://.../ports/9222/devtools/browser/...", ...}

from playwright.async_api import async_playwright
async with async_playwright() as p:
    browser = await p.chromium.connect_over_cdp(
        info["cdp_url"], headers={"Authorization": f"Bearer {API_KEY}"})
    page = browser.contexts[0].pages[0] if browser.contexts[0].pages else await browser.contexts[0].new_page()
    await page.goto("http://localhost:3000")          # what the sandbox itself serves
    await page.screenshot(path="shot.png")
```

Puppeteer cannot send headers on connect; append the key instead:
`puppeteer.connect({browserWSEndpoint: info.cdp_url + "?token=" + API_KEY})`.

The browser runs on the same machine as the agent's commands, so it reaches
`http://localhost:PORT` directly, and cookies and logins it makes persist with the sandbox.

## Files and users

- Paths are absolute; `/workspace` is the working directory and is on the persistent disk.
- Commands run as `user` (uid 1000) with passwordless `sudo`; pass `user="root"` to run as
  root. `apt-get`, `pip`, and `npm` work.
- Docker is installed. Its daemon starts on the first `docker` command (a second or two) and
  keeps running through pause and resume; images and containers are on the persistent disk.
  There is no systemd; long-running services are background processes.
- `files.read` and `files.write` take and return bytes; `read_text` decodes UTF-8.

## Errors

Every error is JSON: `{"error": {"code": "...", "message": "..."}}`.

| HTTP | Code | Meaning | What an agent should do |
|---|---|---|---|
| 400 | `bad_request` | Invalid input (a size not on the menu, a bad path) | Fix the request; do not retry as is |
| 401 | `unauthorized` | Missing or wrong key | Stop; check configuration |
| 403 | `forbidden` | The key is read-only, or not allowed | Use a full-scope key |
| 404 | `not_found` | No such sandbox, file, or process | Create it, or check the id |
| 409 | `conflict` | The sandbox is busy changing state, or a template is building | Retry after a second |
| 429 | `quota_exceeded` | Over a team quota | Pause or delete sandboxes, then retry |
| 502 | `port_unavailable` | Nothing listens on that port yet | Start the server, or wait and retry |
| 503 | `create_failed`, `capacity_unavailable` | No capacity right now | Retry with backoff |

A command that exits non-zero is not an error: the call succeeds with `exit_code` set. A command
that runs past its timeout returns `timed_out: true`.

## Security

- Each sandbox is a Firecracker microVM under the jailer, with its own kernel, its own disk, and
  a cgroup capping CPU and memory. Sandboxes share no files, processes, or network with each
  other.
- Outbound internet access is on by default (`network: "shared"`). The cloud metadata service,
  private address ranges, the host, and other sandboxes are never reachable.
- `network: "none"` blocks everything the sandbox sends, IPv4 and IPv6: the internet, DNS, the
  host, and its own gateway. The block is enforced outside the microVM, so it holds even
  against root in the guest bringing the interface back up, and through pause, resume, and
  moves to another host. The guest's interface is also taken down, so programs fail at once
  with "network is unreachable". The API, the file calls, and ports opened with `expose_port`
  still work, because they reach the sandbox through its agent, not its network. The network
  policy is fixed when the sandbox is created.
- `network: "allowlist"` with `egress_allow` lets a sandbox reach only the destinations you list
  (see below), enforced the same way.

### Egress allowlists

```python
sbx = Sandbox.create(network="allowlist", egress_allow=["pypi.org", "files.pythonhosted.org", "140.82.112.0/20"])
```

- Entries are public IPv4 or IPv6 addresses, CIDR networks (no broader than /8 or /16), or
  hostnames, up to 64. Schemes, ports, paths, and wildcards are refused, as are private,
  loopback, and link-local addresses, which a sandbox can never reach.
- DNS to the sandbox's resolvers is always allowed, so listed hostnames can be looked up.
- Hostnames are enforced by the addresses they resolve to, on the host, when the sandbox
  starts or resumes and again every five minutes. A hostname that resolves to a private address
  adds nothing. Addresses shared with other sites (a CDN) are reachable for those sites too, so
  for strict scoping list the CIDRs you mean.
- Everything else is refused at once, and the list survives pause, resume, and moves between
  hosts. `GET /v1/sandboxes/{id}` shows it as `egress_allow`.
- Do not put your Tilion API key inside a sandbox. Pass the secrets a task needs with `env` when
  creating it, and keep them out of prompts.
- Read-only keys (`scope: "read"`) can list and inspect but not run commands; use them for
  dashboards.

## Limits

| Limit | Value |
|---|---|
| Sizes | 1 to 16 vCPUs, 512 MiB to 32 GiB memory, 5 to 200 GiB disk ([sizes](sizes.md)) |
| Running vCPUs per team | 64 (`max_running_vcpus`) |
| Running memory per team | 128 GiB (`max_running_memory_mib`) |
| Sandboxes per team | 100 (`max_sandboxes`) |
| Default idle pause | 600 s (`idle_pause_after_s`) |
| One agent message | 16 MiB (bigger files: write in parts, or fetch them from inside with `curl` or `git`) |

Quotas can be raised; ask us.


---

# Tilion sandboxes

Persistent, hardware-isolated sandboxes for AI agents: a full Linux microVM with a shell, files,
Docker, and a real Chromium, created in about a millisecond from a warm pool and kept for as
long as you need it.

- [Quickstart](quickstart.md): create a sandbox, run code, pause and resume.
- [For AI agents](agents.md): MCP, the OpenAI Agents SDK, tool definitions for your own loop, and patterns.
- [Sizes](sizes.md): vCPUs, memory, and disk for each sandbox.
- [Durable sessions](persistence.md): what pause keeps, and how sandboxes survive host loss.
- [Templates](templates.md): your own image or Dockerfile as a sandbox template.
- [Accounts](accounts.md): teams, API keys, quotas, and usage.
- [API reference](api.md): every endpoint.
- [Running Tilion yourself](self-hosting.md): hosts, object storage, the control plane.

Measured numbers, with how they were measured, are in [docs/BENCHMARKS.md](../BENCHMARKS.md).
On an AWS c5d.metal host: a sandbox from the warm pool answers its first command 1.4 ms after
the create call; a cold boot takes 44.6 ms; 1,000 sandboxes created at once are all through
their first command in 386 ms.


---

# Quickstart

Set your key (from sign-up or the dashboard) and this service's URL:

```sh
export TILION_API_KEY=tsk_...
export TILION_BASE_URL=https://sandbox.tilion.dev
```

## Python

```sh
pip install https://sandbox.tilion.dev/downloads/tilion_sandbox-0.2.0-py3-none-any.whl
```

```python
from tilion_sandbox import Sandbox

sbx = Sandbox.create()                                  # 2 vCPUs, 2 GiB, the base template
# big = Sandbox.create(vcpus=4, memory_mib=8192, disk_gib=50)   # any size from GET /v1/sizes
print(sbx.exec("python3 -c 'print(6*7)'").stdout)        # 42
sbx.files.write("/workspace/app.py", "print('hello')")
print(sbx.exec("python3 /workspace/app.py").stdout)

proc = sbx.exec("python3 -m http.server 8000", background=True)
url = sbx.expose_port(8000)                              # reach it from outside

sbx.pause()                                              # processes and files are kept
sbx.exec("ls /workspace")                                # any call resumes it
sbx.delete()
```

One sandbox per session, found again by its labels:

```python
sbx, created = Sandbox.get_or_create(labels={"user": "u_123", "project": "demo"})
```

## TypeScript

```sh
npm install https://sandbox.tilion.dev/downloads/tilion-sandbox-0.2.0.tgz
```

```ts
import { Sandbox } from '@tilion/sandbox';

const sbx = await Sandbox.create();
console.log((await sbx.exec("python3 -c 'print(6*7)'")).stdout);
await sbx.files.write('/workspace/app.py', "print('hello')");
await sbx.pause({ durable: true });
await sbx.delete();
```

## A browser in the same sandbox

```python
sbx = Sandbox.create(template="browser")    # Chromium is already running when it starts
info = sbx.start_browser()                  # returns at once; cdp_url is for Playwright or Puppeteer
```

## Command line

```sh
tilion sandbox create                 # prints the sandbox id
tilion sandbox exec sbx_... python3 -V
tilion sandbox pause sbx_...
```


---

# Sandbox sizes

Every sandbox picks its vCPUs, memory, and disk when it is created. Leave them out and you get
the template's own size: 2 vCPUs, 2 GiB of memory, and a 10 GiB disk for `base` and `browser`,
or whatever a team template was built with.

| Setting | Choices |
|---|---|
| `vcpus` | 1, 2, 4, 8, 16 |
| `memory_mib` | 512, 1024, 2048, 4096, 8192, 16384, 32768 |
| `disk_gib` | 5, 10, 20, 50, 100, 200 |

Any combination works. `GET /v1/sizes` returns the same menu and the default.

```python
from tilion_sandbox import Sandbox

sbx = Sandbox.create(vcpus=4, memory_mib=8192, disk_gib=50)
web = Sandbox.create(template="browser", vcpus=2, memory_mib=4096)
```

```ts
const sbx = await Sandbox.create({ vcpus: 4, memoryMib: 8192, diskGib: 50 });
```

```sh
curl -X POST $TILION_BASE_URL/v1/sandboxes -H "Authorization: Bearer $TILION_API_KEY" \
     -H 'Content-Type: application/json' -d '{"vcpus": 4, "memory_mib": 8192, "disk_gib": 50}'
```

## What a size costs the first time

Sandboxes start from a snapshot of a booted machine, and a snapshot's vCPU count, memory size,
and disk size are fixed when it is taken. So the first sandbox of a size that has not been used
with a template before waits while Tilion boots that template once at the new size, with the
same image and the same preparation (Chromium already running, for `browser`), and keeps the
snapshot. Every later sandbox of that size, from any team on any host, starts from it in
milliseconds.

That one-time wait grows with memory, because the whole of guest memory is written once:

| Memory of the new size | First sandbox | Every later one |
|---|---|---|
| 1 GiB | about 5 s | under a second |
| 8 GiB | about 12 s | under a second |
| 16 GiB | about 19 s | under a second |

Common sizes are prepared in advance, so they never wait: the default (2 vCPUs, 2 GiB, 10 GiB
disk) for `base` and `browser`, and 1 vCPU with 1 GiB, 4 vCPUs with 8 GiB, and 8 vCPUs with
16 GiB for `base`, and 4 vCPUs with 8 GiB for `browser`. The default size is also kept warm for
starts of about a millisecond on the host.

## Keeping your sizes ready

If you often start a size or template that is not prepared in advance, ask for standbys of it:

```sh
curl -X PUT $TILION_BASE_URL/v1/team/warm -H "Authorization: Bearer $TILION_API_KEY" \
     -H 'Content-Type: application/json' \
     -d '{"pools": [{"template": "base", "vcpus": 4, "memory_mib": 8192, "count": 5},
                    {"template": "my-team-template", "count": 3}]}'
```

The size is prepared once if it is new, then that many sandboxes of it are kept booted and
waiting, so creating one takes milliseconds even in a burst; each one taken is replaced. Your
team can keep up to `max_warm` standbys in all (10 by default; ask us for more). Standbys hold
no state of yours until one becomes your sandbox.

## What the numbers mean

- **vCPUs** are virtual CPUs on a bare-metal host. A cgroup caps each sandbox at its vCPU count
  (with a short burst allowance), so one sandbox cannot take another's CPU time.
- **Memory** is the guest's RAM. It is allocated as the guest uses it, so a sandbox with 8 GiB
  that uses 300 MB costs the host 300 MB; inside, the guest sees all 8 GiB.
- **Disk** is the writable disk at `/workspace` and for everything the sandbox installs. It is
  kept through pause, resume, and host failover, and counts toward `max_stored_gib`. The disk is
  allocated as it is written.

Running sandboxes count toward your team's `max_running_vcpus` and `max_running_memory_mib`
quotas; paused ones do not. See [accounts](accounts.md).

A size cannot be changed on a running or paused sandbox. To move work to a bigger machine,
create one at the new size and copy what you need.


---

# Durable sessions

A sandbox lives until you delete it. When it is idle it pauses, and pausing keeps everything:
running processes, open files, memory, the filesystem, installed packages, and the browser.
The next call resumes it exactly where it was.

## Pause and resume

- `pause()` stops the VM in about 100 ms. Memory and disk are written as a snapshot.
- Any call on a paused sandbox resumes it first. You do not need to call `resume()`.
- `idle_pause_after_s` (default 600) pauses a sandbox that has had no calls, streams, or open
  terminals for that long. Background processes do not keep it awake; they are frozen with it
  and continue on resume.
- Paused sandboxes cost storage only.

## Object storage

Where the operator has configured object storage, every pause is also uploaded to it in the
background, so a paused sandbox does not depend on the machine it ran on:

- `pause(durable=True)` returns only once the upload is complete.
- Uploads are incremental. Files are split into 2 MiB chunks named by their SHA-256; a second
  pause uploads only the chunks that changed, and data shared with the template is never
  uploaded again.
- Every chunk is checked against its hash when it is read back.
- If the machine running a sandbox is lost, the sandbox comes back on another machine at its
  last pause. A running sandbox loses only what changed since that pause.
- Operators can drain a machine: every sandbox on it is paused into storage and resumes
  elsewhere on its next call.

Snapshots are tied to the CPU model they were made on. Sandboxes move only between machines of
the same type; the platform refuses a mismatched restore rather than resuming a broken guest.


---

# Templates

A template is the image a sandbox starts from. Built-in templates:

| Template | Contents |
|---|---|
| `base` | Ubuntu 24.04, Python 3, Node.js 22, gcc, git, curl, and the Docker engine (its daemon starts on the first `docker` command, in a second or two) |
| `browser` | `base` plus Chromium, already running when the sandbox starts |

## From a sandbox you set up

Set a sandbox up once, the way you want every sandbox to start, then save it:

```python
setup = Sandbox.create(template="browser")
setup.exec("pip install pandas playwright && git clone https://github.com/example/app /workspace/app")
setup.save_as_template("analyst")             # this sandbox keeps running
sbx = Sandbox.create(template="analyst")      # starts with all of that in place
```

```ts
await setup.saveAsTemplate('analyst');
```

The template holds the sandbox exactly as it was when saved: files and installed packages, but
also memory and running processes, so a browser that was logged in or a server that was warm is
the same in every new sandbox. Each new sandbox still gets its own hostname, machine id, and
random state. Because that state lives in memory and on the disk, sandboxes from a saved
template always have the size the sandbox had (asking for another size is refused rather than
starting without the state). Saving again under the same name replaces the template for new
sandboxes; ones already running are unaffected.

## Your own template

Build from any public container image. The image is pulled and unpacked; none of its code runs
during the build.

```python
from tilion_sandbox import Template, Sandbox

Template.build("py312", image="python:3.12-slim", vcpus=1, memory_mib=1024)   # waits until ready
sbx = Sandbox.create(template="py312")                  # 1 vCPU, 1 GiB: the size it was built at
big = Sandbox.create(template="py312", vcpus=8, memory_mib=16384)   # any other size works too
```

The size given to `Template.build` is the template's default. Sandboxes from it can still ask
for any [size](sizes.md); the first one of a new size waits once while it is snapshotted.

```ts
await Template.build('py312', { image: 'python:3.12-slim', vcpus: 1, memoryMib: 1024 });
```

From a Dockerfile (where the operator runs dedicated build hosts):

```python
Template.build("tools", dockerfile="FROM ubuntu:24.04\nRUN apt-get update && apt-get install -y ripgrep")
```

What a build does: it adds Tilion's init and agent and a user `user` (uid 1000) to your image,
boots it once, and snapshots it. Sandboxes from it then start from that snapshot.

- A template's vCPUs and memory are fixed when it is built: every sandbox from it has that size.
- Templates belong to your team; names are lowercase letters, digits, and dashes.
- A template that sandboxes still use cannot be deleted.
- Build status: `GET /v1/templates/{name}` returns `building`, `ready`, or `failed` with the error.


---

# Accounts

## Teams and keys

Everything belongs to a team. An API key acts for its team.

- Keys start with `tsk_`. Tilion stores only their SHA-256 hash; a key is shown once, when made.
- Scope `full` can do everything; scope `read` can only read (no commands, no terminals, no
  changes). Use read keys for dashboards.
- `POST /v1/keys {"name", "scope"}` makes another key for your team (needs a full key).
- `GET /v1/keys` lists keys by prefix, name, scope, and last use; `DELETE /v1/keys/{id}` revokes.

## Quotas

`GET /v1/team` shows your quotas and current use:

| Quota | Counts |
|---|---|
| `max_sandboxes` | sandboxes that are not deleted |
| `max_running_vcpus` | vCPUs of running sandboxes (checked on create and on resume) |
| `max_running_memory_mib` | memory of running sandboxes (checked on create and on resume) |
| `max_warm` | standbys kept ready for the team's warm pools (default 10) |
| `max_stored_gib` | disk size of all sandboxes |

A request over quota gets `429 quota_exceeded`. Pausing a sandbox frees its running vCPUs and memory.

## Events and webhooks

`GET /v1/events` lists what happened to your team's sandboxes, oldest first, each with an `id`,
a time `ts`, a `type`, the `sandbox_id`, and details in `data`:

| Type | When |
|---|---|
| `sandbox.created` | A sandbox started (with its template, size, and `forked_from` for copies) |
| `sandbox.paused`, `sandbox.resumed` | It paused (asked or idle) or resumed |
| `sandbox.deleted` | It was deleted |
| `sandbox.failed` | It could not start or resume (with the `error`) |
| `sandbox.forked` | Copies were made of it (`copies` lists their ids) |
| `template.saved` | It was saved as a template |

Poll with `after` set to the last `id` you saw to pull new events into your own audit log.

To be told instead, register a webhook: `PUT /v1/team/webhook {"url": "https://..."}`. Each event
is POSTed there as JSON with two headers, `X-Tilion-Event` (the type) and `X-Tilion-Signature`:
`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" keyed with your secret>`. Verify it
before trusting the body:

```python
import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes, tolerance_s: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts["v1"], expected) and abs(time.time() - int(parts["t"])) < tolerance_s
```

Delivery is at least once: up to four attempts over about half a minute until your endpoint
answers 2xx. Use the event `id` to drop repeats. The URL must be HTTPS to a public address.
`POST /v1/team/webhook/test` sends a test event.

## Usage

- `GET /v1/usage`: running vCPU-seconds, running GiB-seconds, and paused GiB-seconds.
- `GET /v1/usage/events?since=&until=`: every closed metering interval (sandbox, state, start,
  end, vCPUs, memory), for billing or your own accounting.


---

# API reference

Base URL: your control plane (`TILION_BASE_URL`). Every request sends
`Authorization: Bearer <api key>`. Errors are `{"error": {"code", "message"}}` with an HTTP status.

## Sandboxes

| Method | Path | |
|---|---|---|
| POST | `/v1/sandboxes` | Create. Body: `template`, `vcpus`, `memory_mib`, `disk_gib` (each optional, from `/v1/sizes`; left out, the template's size), `network` (`shared`, `none`, or `allowlist` with `egress_allow`: up to 64 public IPs, CIDRs, or hostnames), `env`, `labels`, `idle_pause_after_s`. Header `Idempotency-Key` makes retries safe. |
| GET | `/v1/sizes` | The vCPU, memory, and disk choices, and the default. See [sizes](sizes.md). |
| GET | `/v1/sandboxes` | List; filter with repeated `label=key=value`. |
| GET | `/v1/sandboxes/{id}` | One sandbox. |
| PATCH | `/v1/sandboxes/{id}` | Change `labels` or `idle_pause_after_s`. |
| DELETE | `/v1/sandboxes/{id}` | Delete, including stored snapshots. |
| POST | `/v1/sandboxes/{id}/pause` | Pause; `?durable=true` waits until it is in object storage. |
| POST | `/v1/sandboxes/{id}/resume` | Resume (any other call also resumes). |
| POST | `/v1/sandboxes/{id}/checkpoint` | Store a running sandbox without stopping it; `?durable=true` waits until it is in object storage. |
| GET | `/v1/sandboxes/{id}/metrics` | What the sandbox uses, from the host's own counters: `cpu_ms`, `cpu_throttled_ms`, `memory_bytes`, `memory_peak_bytes`, `network_sent_bytes`, `network_received_bytes` (cumulative since it last started on its host). Reading never resumes a paused sandbox. |
| POST | `/v1/sandboxes/{id}/template` | Save the sandbox as a team template as it is now (files, packages, memory, processes). Body: `name`. New sandboxes from it start in that state and keep its size. |
| POST | `/v1/sandboxes/{id}/fork` | Copies of the sandbox as it is now (memory, processes, disk). Body: `count` (1 to 16), `labels`. Returns `sandboxes`; the source keeps running. |

## Commands

| Method | Path | |
|---|---|---|
| POST | `/v1/sandboxes/{id}/exec` | Body: `shell` or `cmd`, `cwd`, `env`, `user`, `timeout_s`, `stdin_b64`, `background`, `max_output_bytes`. Returns `exit_code`, `stdout`, `stderr`, `truncated`, `stdout_bytes`, `stderr_bytes`. `?stream=true` streams newline-delimited JSON frames. |
| GET | `/v1/sandboxes/{id}/processes` | Background processes. |
| GET | `/v1/sandboxes/{id}/processes/{pid}/output` | Output since `since_seq`, streamed as one JSON frame per line: `stdout`/`stderr` frames have `text` (decoded) and `data` (base64 bytes), then a `result` frame when it exits. |
| GET | `/v1/sandboxes/{id}/processes/{pid}/wait` | Wait for exit (`timeout_s`). Returns the exit code and the process's `stdout` and `stderr`, kept within `max_output_bytes` each (default 1 MiB); `output=false` leaves them out. |
| POST | `/v1/sandboxes/{id}/processes/{pid}/stdin` | Send input: `data` (text) or `data_b64`, and `eof: true` to close stdin. |
| POST | `/v1/sandboxes/{id}/processes/{pid}/signal` | Send a signal. |
| WS | `/v1/sandboxes/{id}/pty` | Interactive terminal (needs a full-scope key). |

## Files

| Method | Path | |
|---|---|---|
| GET | `/v1/sandboxes/{id}/files?path=` | Read (raw bytes). |
| PUT | `/v1/sandboxes/{id}/files?path=` | Write (raw bytes in the body). |
| GET | `/v1/sandboxes/{id}/files/list?path=` | Directory listing. |
| POST | `/v1/sandboxes/{id}/files/stat` | `{path}`: exists, type, size, mtime. |
| POST | `/v1/sandboxes/{id}/files/mkdir` | `{path}` |
| POST | `/v1/sandboxes/{id}/files/remove` | `{path, recursive}` |
| POST | `/v1/sandboxes/{id}/files/move` | `{src, dst}` |

## Ports and browser

| Method | Path | |
|---|---|---|
| POST | `/v1/sandboxes/{id}/ports` | Expose a port: `{port, public}`; returns its URL. |
| GET | `/v1/sandboxes/{id}/ports` | Exposed and listening ports. |
| DELETE | `/v1/sandboxes/{id}/ports/{port}` | Stop exposing. |
| POST, GET, DELETE | `/v1/sandboxes/{id}/browser` | Start, inspect, or stop Chromium (DevTools endpoint). |

## Templates, team, usage

| Method | Path | |
|---|---|---|
| POST | `/v1/templates` | Build: `name`, `image` or `dockerfile`, `vcpus`, `memory_mib`, `disk_gib`. Returns 202. |
| GET | `/v1/templates`, `/v1/templates/{name}` | List, or one with its build state. |
| DELETE | `/v1/templates/{name}` | Delete (refused while sandboxes use it). |
| GET | `/v1/team` | Team, quotas, current use. |
| POST, GET | `/v1/keys` | Make or list API keys. |
| DELETE | `/v1/keys/{id}` | Revoke. |
| GET | `/v1/usage`, `/v1/usage/events` | Usage totals and metering intervals. |
| GET | `/v1/events` | The team's events, oldest first: `sandbox.created`, `.resumed`, `.paused`, `.deleted`, `.failed`, `.forked`, `template.saved`. `after` (the last id seen), `limit` (up to 1000). |
| GET, PUT | `/v1/team/warm` | The team's warm pools: `pools`, a list of `{template, vcpus, memory_mib, disk_gib, count}` kept ready to start instantly (total up to `max_warm`). PUT replaces them; `[]` drops them. |
| PUT | `/v1/team/webhook` | Body: `url` (public HTTPS). Events are POSTed there, signed; returns the signing `secret` once. |
| GET, DELETE | `/v1/team/webhook` | Show the URL, or stop sending. |
| POST | `/v1/team/webhook/test` | Send a `webhook.test` event; returns whether the URL accepted it. |

## Operators (`TILION_ADMIN_KEY`)

| Method | Path | |
|---|---|---|
| POST, GET | `/v1/admin/teams` | Make a team (`name`, `quotas`) or list them. |
| PATCH | `/v1/admin/teams/{id}` | Change quotas. |
| POST | `/v1/admin/teams/{id}/keys` | Issue a key for a team. |
| GET, POST, DELETE | `/v1/admin/hosts` | List hosts (`?refresh=true` polls them), add one, remove one. |
| POST | `/v1/admin/hosts/drain` | Move every sandbox off a host through object storage. |


---

# Benchmarks

Measured on an AWS c5d.metal host (instance-store NVMe), on
Firecracker 1.17 with the Tilion guest kernel. Times are measured on the host, from the API
call to the sandbox answering, so they leave out your network round trip. The full record, with
methods, raw logs, and every change that moved a number, is in `docs/BENCHMARKS.md` in the
repository.

## Starting sandboxes

| What | Result |
|---|---|
| Create from the warm pool, to the first command answered | 1.4 ms (p50) |
| Cold boot of a fresh microVM to a ready agent, no snapshot | 44.6 ms (p50, 28 boots; p99 46.7 ms) |
| 1,000 sandboxes created at once, all through their first command | 386 ms |
| 200 created at once against a warm pool of 100 | 0.72 s for all 200 |
| Private host memory per idle sandbox | 12.5 MiB |

## Keeping them

| What | Result |
|---|---|
| Freeze for a checkpoint of a running sandbox | 31 ms |
| Durable pause (the whole machine saved to object storage) | about 300 ms with little memory changed since the last save; it grows with the memory written since then (several seconds after heavy churn) |
| Another host takes over a sandbox from object storage | 1.9 s |
| Resume of a paused sandbox, through the public API from outside AWS | about 110 to 160 ms |

## Building templates

| What | Result |
|---|---|
| A Dockerfile built into a template inside a microVM | 20 s |
| A new sandbox size, prepared the first time it is asked for | about 5 s at 1 GiB, 12 s at 8 GiB, 19 s at 16 GiB |

These are from one host type. Your numbers through the public API add the round trip between
you and us-east-1.
