# AI Resources
URL: /docs/core/ai-resources

Use the alineo docs with LLMs and coding agents — llms.txt, per-page Markdown, and agent skills.



The entire alineo documentation is published in formats built for LLMs and coding
agents, not just browsers. These cover every section — Core SDK, Workflow Builder,
Agent SDK, and the CLI.

## llms.txt [#llmstxt]

[`/llms.txt`](https://docs.alineo.tech/llms.txt) is a structured index of every
documentation page, each entry linking to that page's Markdown form. Point an agent
at it to let it discover and pull only the pages it needs.

[`/llms-full.txt`](https://docs.alineo.tech/llms-full.txt) is the entire
documentation concatenated into a single Markdown file — drop it into a context
window whole.

## Per-page Markdown [#per-page-markdown]

Every documentation page has a clean Markdown version at the same path under
`/llms.mdx/`. For example:

```
https://docs.alineo.tech/docs/core/getting-started      (HTML)
https://docs.alineo.tech/llms.mdx/core/getting-started.md   (Markdown)
```

At the top of any page, the **Copy Markdown** button copies that Markdown to your
clipboard, and the **Open** menu opens the page directly in ChatGPT, Claude, or
Cursor, or shows it as raw Markdown.

When citing or linking a page, use its canonical URL — the one without the `.md`
suffix.

## MCP server [#mcp-server]

`https://docs.alineo.tech/mcp` is a [Model Context Protocol](https://modelcontextprotocol.io)
server that lets an MCP client search and read these docs from inside your editor or
agent. It exposes three tools: `search_docs`, `get_doc`, and `list_docs`. It's
read-only and covers documentation only.

<Tabs items="[&#x22;Claude Code&#x22;, &#x22;Cursor&#x22;, &#x22;opencode&#x22;, &#x22;Manual&#x22;]">
  <Tab value="Claude Code">
    ```bash
    claude mcp add --transport http alineo-docs https://docs.alineo.tech/mcp
    ```
  </Tab>

  <Tab value="Cursor">
    <a href="cursor://anysphere.cursor-deeplink/mcp/install?name=alineo-docs&config=eyJ1cmwiOiJodHRwczovL2RvY3MuYWxpbmVvLnRlY2gvbWNwIn0=">
      Add alineo docs to Cursor
    </a>

    Or add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):

    ```json
    {
      "mcpServers": {
        "alineo-docs": { "url": "https://docs.alineo.tech/mcp" }
      }
    }
    ```
  </Tab>

  <Tab value="opencode">
    Add to `opencode.json`:

    ```json
    {
      "mcp": {
        "alineo-docs": { "type": "remote", "url": "https://docs.alineo.tech/mcp" }
      }
    }
    ```
  </Tab>

  <Tab value="Manual">
    Any MCP client that supports a remote (HTTP) server: point it at
    `https://docs.alineo.tech/mcp`. No authentication.
  </Tab>
</Tabs>

This is separate from the alineo SDK/CLI itself, which is what actually *runs*
sandboxed agents.

## Agent skills [#agent-skills]

Install the alineo [skill](https://skills.sh) so your coding agent follows the
SDK's conventions and knows where to look in the docs:

```bash
npx skills add DrejT/alineo --skill alineo
```

Two skills are published from the repo:

| Skill    | Covers                                                                                                                 |
| -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `alineo` | The agent SDK (`load` / `resume` / `attach` / `spawn`, prompt & bash streaming, session control) and the `alineo` CLI. |
| `bun`    | The Bun runtime, package manager, test runner, and bundler used across the repo.                                       |

Add `--skill bun` (or omit `--skill` to install both). The source lives in
[`.agents/skills/`](https://github.com/DrejT/alineo/tree/main/.agents/skills) in the
repo.


---

# Core SDK
URL: /docs/core

The @alineo-labs/sandbox package — sandboxes, exec, file ops, snapshots, storage adapters, and error handling.



<Cards>
  <Card href="/docs/core/getting-started" title="Getting Started" description="Install @alineo-labs/sandbox, run your first sandbox, and understand the core model." />

  <Card href="/docs/core/concepts" title="Concepts" description="Sandboxes, ExecHandle, the event stream, and storage adapters." />

  <Card href="/docs/core/building" title="Building" description="exec, file operations, and snapshots." />

  <Card href="/docs/core/patterns" title="Patterns" description="Error handling, timeouts, run management, and observability." />

  <Card href="/docs/core/adapters" title="Storage Adapters" description="SQLite for local dev, Postgres for production, or bring your own." />

  <Card href="/docs/core/api-reference" title="API Reference" description="Complete reference for Sandbox, SandboxHandle, ExecHandle, and errors." />
</Cards>


---

# Custom adapter
URL: /docs/core/adapters/custom

Implement IStorageAdapter to use any storage backend with @alineo-labs/sandbox.



`IStorageAdapter` is the interface that both `SQLiteAdapter` and `PostgresAdapter` implement. You can implement it yourself to use any storage backend — Redis, DynamoDB, a REST API, etc.

## Interface [#interface]

```ts
export interface IStorageAdapter {
  connect?(): Promise<void>;
  close?(): Promise<void>;
  append(entry: LedgerEntry): Promise<void>;
  readAll(name: string, sandboxId: string): Promise<LedgerEntry[]>;
  lastCheckpoint(name: string, sandboxId: string): Promise<LedgerEntry | null>;
  listSandboxDetails(name: string, opts?: ListSandboxOptions): Promise<SandboxDetails[]>;
  listAllSandboxDetails(opts?: ListSandboxOptions): Promise<SandboxDetails[]>;
  getSandboxDetails(name: string, sandboxId: string): Promise<SandboxDetails | null>;
  deleteSandbox(name: string, sandboxId: string): Promise<void>;
  listCheckpoints(name: string, sandboxId: string): Promise<CheckpointInfo[]>;
  getEnvironment(name: string): Promise<EnvironmentRecord | null>;
  saveEnvironment(record: EnvironmentRecord): Promise<void>;
  deleteEnvironment(name: string): Promise<void>;
  listEnvironments(): Promise<EnvironmentRecord[]>;
}
```

Import types from `@alineo-labs/core`:

```ts
import type {
  IStorageAdapter,
  LedgerEntry,
  LedgerEvent,
  SandboxDetails,
  ListSandboxOptions,
  CheckpointInfo,
  EnvironmentRecord,
} from "@alineo-labs/core";
```

## Method reference [#method-reference]

| Method                               | Required | Description                                                                                                                                                                         |
| ------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connect()`                          | No       | Initialize connections. Called lazily, once, the first time the client needs the adapter — not by any method you call yourself.                                                     |
| `close()`                            | No       | Release connections. Called when the event loop drains naturally (`beforeExit`) — long-running servers never reach this, so the pool stays open for the process lifetime by design. |
| `append(entry)`                      | Yes      | Persist a single ledger event. Called during every live exec.                                                                                                                       |
| `readAll(name, sandboxId)`           | Yes      | Return all events for a session in ascending `ts` order. Used by `resume()`.                                                                                                        |
| `lastCheckpoint(name, sandboxId)`    | Yes      | Return the most recent `checkpoint_created` entry, or `null`.                                                                                                                       |
| `listSandboxDetails(name, opts?)`    | Yes      | Return sessions with this name, newest first.                                                                                                                                       |
| `listAllSandboxDetails(opts?)`       | Yes      | Return all sessions across all names, newest first.                                                                                                                                 |
| `getSandboxDetails(name, sandboxId)` | Yes      | Return a single session, or `null`.                                                                                                                                                 |
| `deleteSandbox(name, sandboxId)`     | Yes      | Delete all events for a session.                                                                                                                                                    |
| `listCheckpoints(name, sandboxId)`   | Yes      | Return all checkpoints for a session, in creation order.                                                                                                                            |
| `getEnvironment(name)`               | Yes      | Return the cached record for a named environment, or `null` if not built yet.                                                                                                       |
| `saveEnvironment(record)`            | Yes      | Upsert an environment record after a successful build.                                                                                                                              |
| `deleteEnvironment(name)`            | Yes      | Remove the record for a named environment. Does not delete the server-side snapshot.                                                                                                |
| `listEnvironments()`                 | Yes      | Return all environment records, newest first.                                                                                                                                       |

## LedgerEntry [#ledgerentry]

```ts
interface LedgerEntry {
  ts: number; // Unix timestamp in milliseconds
  name: string; // sandbox name
  sandboxId: string;
  stepIndex: number; // -1 for sandbox-level events
  branch?: number; // parallel branch index
  event: LedgerEvent;
  payload?: unknown; // event-specific data
  error?: string;
}
```

## Example: in-memory adapter [#example-in-memory-adapter]

Useful for testing:

```ts
import type {
  IStorageAdapter,
  LedgerEntry,
  SandboxDetails,
  ListSandboxOptions,
  CheckpointInfo,
  EnvironmentRecord,
} from "@alineo-labs/core";
import { SandboxStatus, LedgerEvent } from "@alineo-labs/core";

export class MemoryAdapter implements IStorageAdapter {
  private readonly _events: LedgerEntry[] = [];
  private readonly _environments = new Map<string, EnvironmentRecord>();

  async append(entry: LedgerEntry): Promise<void> {
    this._events.push(entry);
  }

  async readAll(name: string, sandboxId: string): Promise<LedgerEntry[]> {
    return this._events
      .filter((e) => e.name === name && e.sandboxId === sandboxId)
      .sort((a, b) => a.ts - b.ts);
  }

  async lastCheckpoint(name: string, sandboxId: string): Promise<LedgerEntry | null> {
    const checkpoints = (await this.readAll(name, sandboxId)).filter(
      (e) => e.event === LedgerEvent.CheckpointCreated,
    );
    return checkpoints.at(-1) ?? null;
  }

  async listSandboxDetails(name: string, opts?: ListSandboxOptions): Promise<SandboxDetails[]> {
    return (await this.listAllSandboxDetails(opts)).filter((d) => d.name === name);
  }

  async listAllSandboxDetails(opts?: ListSandboxOptions): Promise<SandboxDetails[]> {
    const byId = new Map<string, LedgerEntry[]>();
    for (const e of this._events) {
      const key = e.sandboxId;
      if (!byId.has(key)) byId.set(key, []);
      byId.get(key)!.push(e);
    }

    let details: SandboxDetails[] = [];
    for (const [sandboxId, events] of byId) {
      const created = events.find((e) => e.event === LedgerEvent.SandboxCreated);
      const closed = events.find((e) => e.event === LedgerEvent.SandboxClosed);
      if (!created) continue;
      details.push({
        name: created.name,
        sandboxId,
        status: closed ? SandboxStatus.Completed : SandboxStatus.Running,
        startedAt: created.ts,
        completedAt: closed?.ts,
        execCount: events.filter((e) => e.event === LedgerEvent.ExecComplete).length,
      });
    }

    details.sort((a, b) => b.startedAt - a.startedAt);
    if (opts?.status) details = details.filter((d) => d.status === opts.status);
    if (opts?.before) details = details.filter((d) => d.startedAt < opts.before!);
    if (opts?.limit) details = details.slice(0, opts.limit);
    return details;
  }

  async getSandboxDetails(name: string, sandboxId: string): Promise<SandboxDetails | null> {
    const all = await this.listAllSandboxDetails();
    return all.find((d) => d.name === name && d.sandboxId === sandboxId) ?? null;
  }

  async deleteSandbox(name: string, sandboxId: string): Promise<void> {
    const toRemove = this._events
      .filter((e) => e.name === name && e.sandboxId === sandboxId)
      .map((e) => this._events.indexOf(e));
    for (const idx of toRemove.reverse()) {
      this._events.splice(idx, 1);
    }
  }

  async listCheckpoints(name: string, sandboxId: string): Promise<CheckpointInfo[]> {
    const events = await this.readAll(name, sandboxId);
    return events
      .filter((e) => e.event === LedgerEvent.CheckpointCreated)
      .map((e) => {
        const payload = e.payload as { snapshotId: string; name?: string };
        return { snapshotId: payload.snapshotId, tag: payload.name, createdAt: e.ts };
      });
  }

  async getEnvironment(name: string): Promise<EnvironmentRecord | null> {
    return this._environments.get(name) ?? null;
  }

  async saveEnvironment(record: EnvironmentRecord): Promise<void> {
    this._environments.set(record.name, record);
  }

  async deleteEnvironment(name: string): Promise<void> {
    this._environments.delete(name);
  }

  async listEnvironments(): Promise<EnvironmentRecord[]> {
    return [...this._environments.values()].sort((a, b) => b.builtAt - a.builtAt);
  }
}
```

## Using a custom adapter [#using-a-custom-adapter]

Pass it to `Sandbox` the same way as the built-in adapters:

```ts
const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new MemoryAdapter(),
});
// No connect() call needed — the adapter is used as soon as the client is.
```


---

# Storage Adapters
URL: /docs/core/adapters

Choose where @alineo-labs/sandbox persists your run ledger.



<Cards>
  <Card href="/docs/core/adapters/sqlite" title="SQLite" description="Zero-config, WAL mode. The right default for local dev and single-process deploys." />

  <Card href="/docs/core/adapters/postgres" title="Postgres" description="For production multi-process deployments with a shared ledger." />

  <Card href="/docs/core/adapters/custom" title="Custom adapter" description="Implement IStorageAdapter to use any storage backend." />
</Cards>


---

# Postgres adapter
URL: /docs/core/adapters/postgres

Shared ledger for production multi-process deployments.



`@alineo-labs/postgres` stores the run ledger in a Postgres database. Use it when multiple processes need to share the same ledger, or when you need persistent storage on platforms without local disk.

## Install [#install]

```bash
bun add @alineo-labs/postgres
```

## Usage [#usage]

```ts
import { Sandbox } from "@alineo-labs/sandbox";
import { PostgresAdapter } from "@alineo-labs/postgres";

const client = new Sandbox({
  baseUrl: process.env.OPEN_SANDBOX_URL!,
  apiKey: process.env.OPEN_SANDBOX_API_KEY,
  adapter: new PostgresAdapter(process.env.DATABASE_URL!),
});

// No connect() or close() needed — migrations run automatically on first
// use. The pool closes itself only when the event loop drains naturally
// (beforeExit); long-running servers keep it open for the process lifetime.
```

## Constructor [#constructor]

```ts
new PostgresAdapter(connectionString: string)
```

| Argument           | Type     | Description                                                                |
| ------------------ | -------- | -------------------------------------------------------------------------- |
| `connectionString` | `string` | Postgres connection string, e.g. `"postgres://user:pass@host:5432/dbname"` |

## Connection string format [#connection-string-format]

```
postgres://username:password@host:port/database
```

The adapter uses the `postgres` package internally, which also respects the `PGPASSWORD`, `PGUSER`, `PGHOST`, `PGPORT`, and `PGDATABASE` environment variables if you prefer to configure via env.

## Migrations [#migrations]

Migrations run automatically as `CREATE TABLE IF NOT EXISTS` the first time the adapter is used — safe on every startup, no migration tool required.

Schema created:

```sql
CREATE TABLE IF NOT EXISTS alineo_events (
  id          BIGSERIAL   PRIMARY KEY,
  sandbox_id  TEXT        NOT NULL,
  name        TEXT        NOT NULL,
  step_idx    INTEGER     NOT NULL,
  branch      INTEGER,
  event       TEXT        NOT NULL,
  payload     JSONB,
  error       TEXT,
  ts          BIGINT      NOT NULL
);

CREATE INDEX IF NOT EXISTS alineo_events_sandbox_id ON alineo_events(sandbox_id);
CREATE INDEX IF NOT EXISTS alineo_events_name ON alineo_events(name);

CREATE TABLE IF NOT EXISTS alineo_environments (
  name        TEXT    PRIMARY KEY,
  snapshot_id TEXT    NOT NULL,
  image       TEXT    NOT NULL,
  built_at    BIGINT  NOT NULL
);
```

## When to use [#when-to-use]

* **Production deployments** — multiple workers sharing a ledger
* **Cloud environments** — no persistent local disk available
* **Shared visibility** — multiple services or dashboards reading run history

## Environment-based config [#environment-based-config]

```ts
const adapter = new PostgresAdapter(process.env.DATABASE_URL ?? "postgres://localhost/alineo_dev");
```

For production, set `DATABASE_URL` in your environment and never hardcode credentials.

## When to use SQLite instead [#when-to-use-sqlite-instead]

For local development and single-process scripts, `@alineo-labs/sqlite` is simpler — no database server required. See [SQLite adapter](/docs/core/adapters/sqlite).


---

# SQLite adapter
URL: /docs/core/adapters/sqlite

Zero-config storage with WAL mode. The right default for local dev and single-process deploys.



`@alineo-labs/sqlite` is the recommended adapter for local development and single-process applications. It requires no external services — just a file path.

## Install [#install]

```bash
bun add @alineo-labs/sqlite
```

## Usage [#usage]

```ts
import { Sandbox } from "@alineo-labs/sandbox";
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new SQLiteAdapter("./ledger.db"),
});

// No connect() or close() needed — the file is created and migrations run
// automatically the first time the adapter is used.
```

`CREATE TABLE IF NOT EXISTS` migrations run automatically on first use, so it's safe to construct the adapter fresh on every startup.

## Constructor [#constructor]

```ts
new SQLiteAdapter(path: string)
```

| Argument | Type     | Description                                                                                         |
| -------- | -------- | --------------------------------------------------------------------------------------------------- |
| `path`   | `string` | File path for the SQLite database. Use `":memory:"` for an in-memory database (data lost on close). |

## WAL mode [#wal-mode]

`SQLiteAdapter` enables WAL (Write-Ahead Logging) mode automatically the first time it's used. WAL mode prevents writers from blocking readers, so multiple concurrent sandbox sessions in the same process are safe.

## When to use [#when-to-use]

* **Local development** — no infra required, ledger is a file you can inspect
* **Single-process scripts** — CLI tools, one-off runs
* **Testing** — use `":memory:"` for a clean database per test run

```ts
// In-memory: no file, data gone on close
const adapter = new SQLiteAdapter(":memory:");
```

## When to switch to Postgres [#when-to-switch-to-postgres]

Switch to `@alineo-labs/postgres` when:

* Multiple processes need to share the same ledger
* You're deploying to a platform without persistent local disk
* You need to query ledger data with SQL from external tools

## Schema [#schema]

The following is created automatically on first use:

```sql
CREATE TABLE IF NOT EXISTS alineo_events (
  id          INTEGER  PRIMARY KEY AUTOINCREMENT,
  sandbox_id  TEXT     NOT NULL,
  name        TEXT     NOT NULL,
  step_idx    INTEGER  NOT NULL,
  branch      INTEGER,
  event       TEXT     NOT NULL,
  payload     TEXT,
  error       TEXT,
  ts          INTEGER  NOT NULL
);

CREATE INDEX IF NOT EXISTS alineo_events_sandbox_id ON alineo_events(sandbox_id);
CREATE INDEX IF NOT EXISTS alineo_events_name ON alineo_events(name);

CREATE TABLE IF NOT EXISTS alineo_environments (
  name        TEXT    PRIMARY KEY,
  snapshot_id TEXT    NOT NULL,
  image       TEXT    NOT NULL,
  built_at    INTEGER NOT NULL
);
```


---

# Sandbox
URL: /docs/core/api-reference/alineo-client

The main client — sandbox(), resume(), restoreSnapshot(), connect(), and sandboxes management.



```ts
import { Sandbox } from "@alineo-labs/sandbox";
```

## Constructor [#constructor]

```ts
new Sandbox(options: SandboxClientOptions)
```

### SandboxClientOptions [#sandboxclientoptions]

| Option             | Type               | Required | Description                                                                                                                                            |
| ------------------ | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `baseUrl`          | `string`           | Yes      | OpenSandbox server URL, e.g. `"http://localhost:8080"`                                                                                                 |
| `apiKey`           | `string`           | No       | OpenSandbox API key. Empty string for local dev with no auth.                                                                                          |
| `adapter`          | `IStorageAdapter`  | Yes      | Storage adapter for the run ledger                                                                                                                     |
| `maxConcurrency`   | `number`           | No       | Max simultaneous active sandboxes. Omit for no limit.                                                                                                  |
| `useServerProxy`   | `boolean`          | No       | Route execd and proxy traffic through the server. Required when the server runs in Docker via `alineo init`. Defaults to `false`.                      |
| `credentialBroker` | `CredentialBroker` | No       | Backend for `sb.credentials.*`. Defaults to `OpenSandboxCredentialBroker` (`@alineo-labs/vault`) — see [Credentials](/docs/core/concepts/credentials). |

```ts
const client = new Sandbox({
  baseUrl: process.env.OPEN_SANDBOX_URL ?? "http://localhost:8080",
  apiKey: process.env.OPEN_SANDBOX_API_KEY ?? "",
  adapter: new SQLiteAdapter("./ledger.db"),
  maxConcurrency: 10,
});
```

## Methods [#methods]

### sandbox() [#sandbox]

```ts
await client.sandbox(opts: SandboxOptions): Promise<SandboxHandle>
```

Creates a container, waits until it reaches `Running` state, and returns a live `SandboxHandle` object. Writes a `sandbox_created` event to the ledger.

If `maxConcurrency` is set, awaits a slot before creating the container. The slot is released when `sb.close()` is called.

See [Sandboxes](/docs/core/concepts/sandboxes) for `SandboxOptions` reference.

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "512Mi" },
  name: "my-run",
});
try {
  await sb.exec("echo hello").pipe(process.stdout);
} finally {
  await sb.close();
}
```

### resume() [#resume]

```ts
await client.resume(
  sandboxId: string,
  opts?: { tag?: string; resolveCredential?: CredentialResolver },
): Promise<SandboxHandle>
```

Restores a sandbox session from its last checkpoint (or the checkpoint matching `opts.tag`, if given):

1. Reads the ledger to find the last `checkpoint_created` event (or the last one matching `opts.tag`)
2. Creates a new container from the snapshot
3. Populates a replay cache from execs that completed before the checkpoint
4. Re-registers any credentials the sandbox had bound (see [Credentials](/docs/core/concepts/credentials)) — `opts.resolveCredential` supplies values that can't be re-derived automatically
5. Returns a `SandboxHandle` — pre-checkpoint execs return cached results; later execs run live

Throws `SandboxClientError` (404) if the session is not found or has no matching checkpoint. Throws `SandboxError` if a bound credential can't be resolved.

```ts
const sbResume = await client.resume(originalSandboxId);
try {
  await sbResume.exec("pip install -q requests").pipe(process.stdout); // replayed
  await sbResume.exec("python3 script.py").pipe(process.stdout); // live
} finally {
  await sbResume.close();
}

// Resume from a specific named checkpoint instead of the most recent one:
const sbTagged = await client.resume(originalSandboxId, { tag: "after-apt" });
```

### restoreSnapshot() [#restoresnapshot]

```ts
await client.restoreSnapshot(
  snapshotId: string,
  name: string,
  resources: { cpu: string; memory: string; gpu?: string },
): Promise<SandboxHandle>
```

Creates a fresh sandbox from a snapshot ID **without** exec replay — unlike `resume()`, the new sandbox starts with a clean exec history; nothing from the ledger is replayed. Use this when you want to restore a checkpointed environment and run new commands from scratch, rather than continue exactly where the checkpoint left off.

This is also what `sb.fork()` uses under the hood — see [Forking sandboxes](/docs/core/patterns/fork) for the relationship between `checkpoint()`, `fork()`, `resume()`, and `restoreSnapshot()`.

```ts
const snapshotId = await sb.checkpoint();
await sb.close();

// Later — restore and run fresh commands:
const sb2 = await client.restoreSnapshot(snapshotId, "my-sandbox", {
  cpu: "500m",
  memory: "256Mi",
});
await sb2.exec("npm test");
await sb2.close();
```

### connect() [#connect]

```ts
await client.connect(
  sandboxId: string,
  name: string,
  opts?: { resources?: { cpu: string; memory: string; gpu?: string } },
): Promise<SandboxHandle>
```

Attaches to an already-`Running` sandbox by ID, no snapshot or ledger replay involved — the container must already be running (throws `SandboxClientError` (409) otherwise). Use this to reconnect to a live sandbox created outside the current process, e.g. from a saved `sandboxId`.

`opts.resources` is only needed if you intend to call `.fork()` on the returned `SandboxHandle` afterwards — the control API doesn't echo back a running sandbox's own resource limits, so there's no way to discover them here; without it, `.fork()` is unavailable on the returned object.

```ts
// In a new process, reconnect to a sandbox started earlier:
const sb = await client.connect(savedSandboxId, "my-sandbox");
const { stdout } = await sb.exec("cat /results.txt");
await sb.close();
```

### sandboxes [#sandboxes]

```ts
client.sandboxes: {
  list(opts?: ListSandboxOptions): Promise<SandboxDetails[]>
  listByName(name: string, opts?: ListSandboxOptions): Promise<SandboxDetails[]>
  get(name: string, sandboxId: string): Promise<SandboxDetails | null>
  delete(name: string, sandboxId: string): Promise<void>
}
```

Access to the ledger's session history.

```ts
// List all sessions, newest first
const all = await client.sandboxes.list();

// Filter
const running = await client.sandboxes.list({ status: SandboxStatus.Running, limit: 20 });

// By name
const named = await client.sandboxes.listByName("my-ci-job");

// Single session
const session = await client.sandboxes.get("my-job", sandboxId);

// Delete
await client.sandboxes.delete("my-job", sandboxId);
```

### ListSandboxOptions [#listsandboxoptions]

| Option   | Type            | Description                                                  |
| -------- | --------------- | ------------------------------------------------------------ |
| `status` | `SandboxStatus` | Filter by status: `"running"` or `"completed"`               |
| `limit`  | `number`        | Max results to return                                        |
| `before` | `number`        | Return only sessions started before this Unix timestamp (ms) |

## SandboxHandle class [#sandboxhandle-class]

See [SandboxHandle](#sandboxhandle-class) below for the object returned by `sandbox()` and `resume()`.

### SandboxHandle methods [#sandboxhandle-methods]

| Method                            | Returns                               | Description                                           |
| --------------------------------- | ------------------------------------- | ----------------------------------------------------- |
| `sb.exec(cmd, opts?)`             | `ExecHandle`                          | Run a shell command                                   |
| `sb.execCode(code, opts?)`        | `ExecHandle`                          | Run code via the interpreter                          |
| `sb.proxy(port)`                  | `Promise<{ url, headers }>`           | Get a proxied URL for an in-sandbox port              |
| `sb.metrics()`                    | `Promise<{ cpu, memory, timestamp }>` | Current CPU and memory usage                          |
| `sb.writeFile(path, content)`     | `Promise<void>`                       | Write a UTF-8 file into the container                 |
| `sb.readFile(path)`               | `Promise<string>`                     | Read a file from the container as a string            |
| `sb.moveFile(from, to)`           | `Promise<void>`                       | Move or rename a file                                 |
| `sb.deleteFile(path)`             | `Promise<void>`                       | Delete a file                                         |
| `sb.createDirectory(path)`        | `Promise<void>`                       | Create a directory (and parents)                      |
| `sb.deleteDirectory(path)`        | `Promise<void>`                       | Delete a directory                                    |
| `sb.getFileInfo(path)`            | `Promise<FileInfo>`                   | File metadata: size, type, mode, timestamps           |
| `sb.replaceInFiles(replacements)` | `Promise<void>`                       | In-place substring replacement across files           |
| `sb.transfer(path, target)`       | `Promise<void>`                       | Copy a file to another `SandboxHandle` instance       |
| `sb.searchFiles(pattern, path?)`  | `Promise<string[]>`                   | Search for files matching a glob                      |
| `sb.listDirectory(path, opts?)`   | `Promise<FileInfo[]>`                 | List directory entries with metadata                  |
| `sb.checkpoint(name?)`            | `Promise<string>`                     | Snapshot the container state, returns the snapshot ID |
| `sb.listCheckpoints()`            | `Promise<CheckpointInfo[]>`           | All checkpoints for this sandbox                      |
| `sb.fork(tag?, runId?, opts?)`    | `Promise<SandboxHandle>`              | Snapshot and return an independent copy               |
| `sb.close()`                      | `Promise<void>`                       | Delete the container and release resources            |

### sb.credentials [#sbcredentials]

```ts
sb.credentials: {
  set(name: string, value: string, binding: CredentialBinding, source?: CredentialSource): Promise<void>
  patch(name: string, changes: Partial<{ value: string; binding: CredentialBinding }>): Promise<void>
  remove(name: string): Promise<void>
  listBindings(): Promise<Array<{ name: string; binding: CredentialBinding }>>
}
```

Register, update, remove, and list credentials injected into this sandbox's outbound requests — requires the sandbox to have been created with `credentialProxy: true`. See [Credentials](/docs/core/concepts/credentials).

### SandboxHandle properties [#sandboxhandle-properties]

| Property       | Type     | Description                          |
| -------------- | -------- | ------------------------------------ |
| `sb.sandboxId` | `string` | OpenSandbox container ID             |
| `sb.name`      | `string` | User-provided name or auto-generated |


---

# Errors
URL: /docs/core/api-reference/errors

CommandError, SandboxError, ExecConnectionError, WorkflowError, and SandboxClientError — the full alineo error hierarchy.



```ts
import {
  CommandError,
  SandboxError,
  ExecConnectionError,
  WorkflowError,
  SandboxClientError,
} from "@alineo-labs/sandbox";
```

## Error hierarchy [#error-hierarchy]

```
Error
├── WorkflowError            — base class for Sandbox-level errors
│   ├── SandboxError        — sandbox lifecycle failure: create/boot/Running, paused, unsupported fork(), or snapshot
│   ├── ExecConnectionError — execd not ready after retry window
│   └── CommandError        — non-zero exit code in strict mode
└── SandboxClientError                — client-level errors from Sandbox itself (not a WorkflowError)
```

`SandboxError`, `ExecConnectionError`, and `CommandError` all extend `WorkflowError`, which extends `Error` — catch all three with `e instanceof WorkflowError`. `SandboxClientError` is a separate, sibling class thrown by `Sandbox` client methods (`sandbox()`, `resume()`, `sandboxes.*`, `environment()`) rather than by `Sandbox` methods — see [SandboxClientError](#alineoerror) below.

## WorkflowError [#workflowerror]

Base class. Has `message` and `name` properties. `name` is always `"WorkflowError"`.

```ts
import { WorkflowError } from "@alineo-labs/sandbox";

try {
  await sb.exec("cmd");
} catch (e) {
  if (e instanceof WorkflowError) {
    console.error("alineo error:", e.message);
  } else {
    throw e;
  }
}
```

## CommandError [#commanderror]

Thrown when `sb.exec()` exits with a non-zero code and `strict: true` (the default).

```ts
import { CommandError } from "@alineo-labs/sandbox";

try {
  await sb.exec("exit 42");
} catch (e) {
  if (e instanceof CommandError) {
    console.error(`exit ${e.exitCode}`); // 42
    console.error(`command: ${e.command}`); // "exit 42"
    console.error(`sandbox: ${e.sandboxId}`);
  }
}
```

### Properties [#properties]

| Property    | Type     | Description                           |
| ----------- | -------- | ------------------------------------- |
| `exitCode`  | `number` | The process exit code                 |
| `command`   | `string` | The command string that was run       |
| `sandboxId` | `string` | The sandbox ID where it ran           |
| `message`   | `string` | `"Command exited with code N: <cmd>"` |
| `name`      | `string` | `"CommandError"`                      |

### Avoiding CommandError [#avoiding-commanderror]

Pass `{ strict: false }` to get the exit code in the result:

```ts
const { exitCode } = await sb.exec("test -f /etc/hosts", { strict: false });
```

## SandboxError [#sandboxerror]

Thrown by methods on `SandboxHandle` itself: `resume()` if the container never reaches `Running` (or enters `Failed`/`Terminated`), an exec call while the sandbox is paused, `fork()` when the deps don't support it, or a failed snapshot wait.

A failure during `client.sandbox()`'s own initial creation throws `SandboxClientError`, not `SandboxError` — see below.

```ts
import { SandboxError } from "@alineo-labs/sandbox";

try {
  await sb.resume(); // sandbox previously pause()d, now stuck
} catch (e) {
  if (e instanceof SandboxError) {
    console.error(e.message); // e.g. "Sandbox entered Failed: ..." or "sandbox is paused — call resume() first"
    console.error(e.sandboxId ?? ""); // sandbox ID if assigned before failure
  }
}
```

### Properties [#properties-1]

| Property    | Type                  | Description                                         |
| ----------- | --------------------- | --------------------------------------------------- |
| `sandboxId` | `string \| undefined` | Container ID if one was assigned before the failure |
| `message`   | `string`              | Describes what failed                               |
| `name`      | `string`              | `"SandboxError"`                                    |

## ExecConnectionError [#execconnectionerror]

Thrown when the execd daemon inside the container never becomes ready within the retry window (\~15 seconds). The container is `Running` from OpenSandbox's perspective, but the exec daemon isn't accepting connections.

```ts
import { ExecConnectionError } from "@alineo-labs/sandbox";

try {
  await sb.exec("echo test");
} catch (e) {
  if (e instanceof ExecConnectionError) {
    console.error(e.message); // "execd not ready for sandbox <id>"
    console.error(e.sandboxId); // the sandbox ID
  }
}
```

### Properties [#properties-2]

| Property    | Type     | Description                          |
| ----------- | -------- | ------------------------------------ |
| `sandboxId` | `string` | The sandbox ID                       |
| `message`   | `string` | `"execd not ready for sandbox <id>"` |
| `name`      | `string` | `"ExecConnectionError"`              |

This usually means the container image doesn't include execd, or the container started but the execd process crashed.

## SandboxClientError [#sandboxclienterror]

Thrown by the `Sandbox` client for API-level errors (404 not found, timeouts waiting for Running state):

```ts
import { SandboxClientError } from "@alineo-labs/sandbox";

try {
  await client.resume("nonexistent-id");
} catch (e) {
  if (e instanceof SandboxClientError) {
    console.error(e.message); // "Session nonexistent-id not found"
    console.error(e.status); // 404
  }
}
```

`SandboxClientError` is not a `WorkflowError` — it's a separate class for client-level errors.


---

# API Reference
URL: /docs/core/api-reference

Complete reference for every public symbol exported from @alineo-labs/sandbox.



<Cards>
  <Card href="/docs/core/api-reference/alineo-client" title="Sandbox" description="The main client — sandbox(), resume(), restoreSnapshot(), connect(), and sandboxes management. No adapter connect()/close() call needed." />

  <Card href="/docs/core/api-reference/workflow-run" title="ExecHandle" description="The PromiseLike returned by sb.exec() — pipe(), stdout() generator, result(), and await." />

  <Card href="/docs/workflow/api-reference/builder" title="Builder API" description="workflow(), WorkflowBuilder, and SandboxBuilder from @alineo-labs/workflow." />

  <Card href="/docs/core/api-reference/errors" title="Errors" description="CommandError, SandboxError, ExecConnectionError, WorkflowError, SandboxClientError." />
</Cards>


---

# ExecHandle
URL: /docs/core/api-reference/workflow-run

The object returned by sb.exec() and sb.execCode() — PromiseLike<ExecResult> with pipe(), stdout(), and result().



```ts
import type { ExecHandle, ExecResult } from "@alineo-labs/sandbox";
```

`ExecHandle` is returned by `sb.exec()` and `sb.execCode()`. It implements `PromiseLike<ExecResult>` — you can `await` it directly, pipe it, or consume it as an async generator.

## Consumption modes [#consumption-modes]

### await (direct) [#await-direct]

```ts
const { stdout, stderr, exitCode } = await sb.exec("node --version");
```

Resolves after the command completes. `stdout` is the full buffered stdout string.

### pipe() [#pipe]

```ts
await sb.exec("npm run build").pipe(process.stdout);
```

Streams stdout chunks to any writable with a `write(chunk: string)` method. Resolves when the command completes.

### stdout() [#stdout]

```ts
for await (const chunk of sb.exec("npm test").stdout()) {
  process.stdout.write(chunk);
}
```

Async generator yielding stdout chunks as they arrive.

### result() [#result]

```ts
const handle = sb.exec("npm test");
// ... other work ...
const { stdout, stderr, exitCode } = await handle.result();
```

Explicit promise form. Equivalent to `await handle`.

## ExecResult [#execresult]

```ts
interface ExecResult {
  stdout: string; // full stdout as a string
  stderr: string; // full stderr as a string
  exitCode: number; // process exit code (0 = success)
}
```

## Strict mode [#strict-mode]

By default, `exec()` throws `CommandError` if `exitCode !== 0` after the handle resolves. Pass `{ strict: false }` to get the result instead:

```ts
const { exitCode } = await sb.exec("test -f /etc/hosts", { strict: false });
```

See [Error handling](/docs/core/patterns/error-handling) for more on `CommandError`.

## Multiple consumers [#multiple-consumers]

An `ExecHandle` can only be consumed once — the underlying stream starts draining as soon as `sb.exec()`/`sb.execCode()` is called, in the `ExecHandle` constructor, whether or not anything ever consumes it. All consumption modes (`pipe`, `stdout`, `await`, `result`) share the same internal chunk buffer.

```ts
const handle = sb.exec("long-running-command");

// These share the same stream — start them before any awaiting
void handle.pipe(process.stdout); // stream as it runs
const { exitCode } = await handle.result(); // get result when done
```

## In the workflow builder [#in-the-workflow-builder]

The `SandboxBuilder` in `@alineo-labs/workflow` queues `exec()` calls but doesn't return `ExecHandle` instances — ops are executed during flush. Use `readFile(path, as)` to capture values into `vars`:

```ts
const { vars } = await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.exec("node --version > /tmp/ver.txt");
    sb.readFile("/tmp/ver.txt", "version");
  })
  .result();

console.log(vars.version);
```


---

# Control flow
URL: /docs/core/building/control-flow

retry, when, forEach, parallel, and sequence — composable workflow primitives from @alineo-labs/workflow.



Control-flow primitives are provided by `@alineo-labs/workflow`. They're available on `SandboxBuilder` (the callback argument in `.sandbox()`) and are queued synchronously — nothing runs until `.pipe()` or `.result()` is awaited.

```bash
bun add @alineo-labs/workflow
```

## retry [#retry]

Retry an inner callback up to `maxAttempts` times on failure. Retries on any thrown error, including `CommandError` from a non-zero exit.

```ts
await workflow(client)
  .sandbox({ image: "ubuntu:22.04", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.retry(
      5,
      (sb) => {
        sb.exec(`
          R=$((RANDOM % 2))
          if [ $R -eq 0 ]; then exit 1; fi
          echo "success"
        `);
      },
      { delayMs: 200, backoff: "exponential" },
    );
  })
  .pipe(process.stdout);
```

### RetryOptions [#retryoptions]

| Option    | Type                       | Default   | Description                                    |
| --------- | -------------------------- | --------- | ---------------------------------------------- |
| `delayMs` | `number`                   | `1000`    | Delay between retries in milliseconds          |
| `backoff` | `"fixed" \| "exponential"` | `"fixed"` | `"exponential"` doubles the delay each attempt |

With exponential backoff, attempt delays are: `delayMs`, `delayMs * 2`, `delayMs * 4`, etc.

## when [#when]

Branch on runtime state. The predicate receives the current context: `stdout` accumulated across every exec run so far in the sandbox, `exitCode` from the most recent exec, and `vars` captured so far:

```ts
await workflow(client)
  .sandbox({ image: "ubuntu:22.04", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.exec("test -f /etc/hostname", { strict: false });
    sb.when(
      (ctx) => ctx.exitCode === 0,
      (sb) => {
        sb.exec('echo "/etc/hostname exists"');
      },
      (sb) => {
        sb.exec('echo "/etc/hostname missing"');
      },
    );
  })
  .pipe(process.stdout);
```

The `else` branch (third argument) is optional. `ctx.stdout` is accumulated stdout from all execs so far. `ctx.vars` holds values captured with `sb.readFile(path, as)`.

## forEach [#foreach]

Iterate over a list of items. Each item runs the inner callback in the same sandbox:

```ts
await workflow(client)
  .sandbox({ image: "ubuntu:22.04", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.forEach(["alpha.txt", "beta.txt", "gamma.txt"], (sb, item) => {
      sb.exec(`echo "writing /tmp/${item}" && echo hello > /tmp/${item}`);
    });
    sb.exec("ls /tmp/*.txt");
  })
  .pipe(process.stdout);
```

### ForEachOptions [#foreachoptions]

| Option        | Type     | Default | Description                                                         |
| ------------- | -------- | ------- | ------------------------------------------------------------------- |
| `concurrency` | `number` | `1`     | Number of items to process simultaneously (within the same sandbox) |

## parallel [#parallel]

Run the same operation across multiple sandbox configurations simultaneously:

```ts
await workflow(client)
  .parallel(
    [
      { image: "node:20", resources: { cpu: "500m", memory: "512Mi" } },
      { image: "node:22", resources: { cpu: "500m", memory: "512Mi" } },
      { image: "node:24", resources: { cpu: "500m", memory: "512Mi" } },
    ],
    (sb) => {
      sb.exec("npm ci");
      sb.exec("npm test");
    },
  )
  .pipe(process.stdout);
```

Each configuration gets its own container. All run concurrently; results are merged in declaration order.

## sequence [#sequence]

Run sandboxes one after another, passing each stage's result to the next:

```ts
await workflow(client)
  .sequence([
    {
      image: "node:22",
      name: "build",
      resources: { cpu: "1", memory: "512Mi" },
      run: (sb) => {
        sb.exec("npm run build");
        sb.readFile("/dist/bundle.js", "bundle");
      },
    },
    {
      image: "ubuntu:22.04",
      name: "deploy",
      resources: { cpu: "500m", memory: "256Mi" },
      run: (sb, prev) => {
        // prev.vars.bundle is available here
        sb.exec(`echo "deploying bundle (${String(prev?.vars.bundle ?? "").length} bytes)"`);
      },
    },
  ])
  .pipe(process.stdout);
```

## Composing control flow [#composing-control-flow]

`retry`, `when`, and `forEach` compose — you can nest them freely:

```ts
sb.forEach(["a", "b", "c"], (sb, item) => {
  sb.retry(
    3,
    (sb) => {
      sb.exec(`process ${item}`);
    },
    { backoff: "exponential" },
  );

  sb.when(
    (ctx) => ctx.exitCode === 0,
    (sb) => sb.exec(`echo "${item} succeeded"`),
    (sb) => sb.exec(`echo "${item} failed after retries"`),
  );
});
```


---

# exec & execCode
URL: /docs/core/building/exec

Run shell commands with exec() and interpreted code with execCode().



## exec() [#exec]

`sb.exec(cmd, opts?)` runs a shell command inside the container and returns an `ExecHandle`.

```ts
// Await for full result
const { stdout, stderr, exitCode } = await sb.exec("node --version");

// Stream stdout in real time
await sb.exec("npm run build").pipe(process.stdout);

// Iterate chunks
for await (const chunk of sb.exec("npm test").stdout()) {
  process.stdout.write(chunk);
}
```

### Multi-line scripts [#multi-line-scripts]

Pass a multi-line string to run a bash script:

```ts
const script = `
#!/bin/bash
set -euo pipefail
echo "=== system info ==="
uname -a
echo "=== disk usage ==="
df -h /
`.trim();

await sb.exec(script).pipe(process.stdout);
```

### ExecOptions [#execoptions]

| Option      | Type                     | Default | Description                                                                         |
| ----------- | ------------------------ | ------- | ----------------------------------------------------------------------------------- |
| `strict`    | `boolean`                | `true`  | Throw `CommandError` on non-zero exit. Set to `false` to get the exit code instead. |
| `cwd`       | `string`                 | —       | Working directory inside the sandbox                                                |
| `env`       | `Record<string, string>` | —       | Extra environment variables for this exec only                                      |
| `timeoutMs` | `number`                 | —       | Abort the command after this many milliseconds                                      |

### Exec timeout [#exec-timeout]

Use `timeoutMs` to abort a command that runs too long:

```ts
try {
  await sb.exec("npm test", { timeoutMs: 30_000 }); // abort after 30s
} catch (e) {
  if (e instanceof CommandError) {
    console.error("timed out or failed:", e.exitCode);
  }
}
```

### Strict vs non-strict [#strict-vs-non-strict]

```ts
// strict (default) — throws CommandError on non-zero exit
try {
  await sb.exec("exit 42");
} catch (e) {
  if (e instanceof CommandError) {
    console.error(`exit ${e.exitCode}`); // 42
  }
}

// non-strict — returns exitCode in result
const { exitCode } = await sb.exec("test -f /etc/hostname", { strict: false });
if (exitCode === 0) {
  console.log("file exists");
}
```

### Using captured output in subsequent commands [#using-captured-output-in-subsequent-commands]

```ts
const { stdout: nodeVersion } = await sb.exec('node -e "process.stdout.write(process.version)"');

await sb.exec(`echo "Building with ${nodeVersion.trim()}"`).pipe(process.stdout);
```

Template literals with exec output work because the exec is already awaited — there's no ordering issue.

## execCode() [#execcode]

`sb.execCode(code, opts?)` runs code via the sandbox's code interpreter (Python, JS, TypeScript) using the execd `/code` endpoint. Requires a code-interpreter image.

```ts
import { CodeLanguage } from "@alineo-labs/sandbox";

const sb = await client.sandbox({
  image: "opensandbox/code-interpreter",
  resources: { cpu: "500m", memory: "512Mi" },
  // Required for this image — starts the Jupyter kernel service execCode() depends on.
  entrypoint: ["/opt/code-interpreter/code-interpreter.sh"],
});
```

### Stateless execution [#stateless-execution]

Each call without a context runs in an isolated interpreter session:

```ts
await sb
  .execCode(
    `
import sys, math
print(f"Python {sys.version.split()[0]}")
print(f"pi = {math.pi:.6f}")
`.trim(),
  )
  .pipe(process.stdout);
```

### Stateful execution [#stateful-execution]

Contexts must be created first via `createCodeContext()` — you can't hand-roll one. Pass the returned context to make variables persist across calls:

```ts
const ctx = await sb.createCodeContext(CodeLanguage.Python);

await sb.execCode(`data = [2**i for i in range(8)]`, { context: ctx });
await sb.execCode(`print(f"sum = {sum(data)}")`, { context: ctx }).pipe(process.stdout);
// sum = 255
```

### ExecCodeOptions [#execcodeoptions]

| Option    | Type                                     | Description                                                                            |
| --------- | ---------------------------------------- | -------------------------------------------------------------------------------------- |
| `context` | `{ id: string, language: CodeLanguage }` | Stateful interpreter session. Variables defined in one call are available in the next. |

### CodeLanguage [#codelanguage]

```ts
import { CodeLanguage } from "@alineo-labs/sandbox";

CodeLanguage.Python; // "python"
CodeLanguage.JavaScript; // "javascript"
CodeLanguage.TypeScript; // "typescript"
```

## proxy() [#proxy]

```ts
await sb.proxy(port: number): Promise<{ url: string; headers: Record<string, string> }>
```

Returns a proxied URL and auth headers for a port running inside the sandbox. Use this to send HTTP requests to a server started with `exec`.

```ts
await sb.exec("node server.js &");
// wait a moment for the server to be ready
await sb.exec("sleep 1");

const { url, headers } = await sb.proxy(3000);
const res = await fetch(`${url}/health`, { headers });
console.log(await res.text());
```

The returned `url` is routable from outside the sandbox. The `headers` contain the execd auth token required by OpenSandbox's proxy layer — pass them along with every request.

## metrics() [#metrics]

```ts
await sb.metrics(): Promise<{ cpu: number; memory: number; timestamp: string }>
```

Returns current CPU and memory usage for the sandbox container.

```ts
const { cpu, memory, timestamp } = await sb.metrics();
console.log(`CPU: ${cpu}, Memory: ${memory} at ${timestamp}`);
```

Useful for monitoring resource consumption during heavy workloads or detecting runaway processes before a timeout fires.


---

# File operations
URL: /docs/core/building/file-ops

Write, read, move, delete, search, list, patch, and transfer files inside the sandbox container.



`SandboxHandle` exposes file operation methods that cover reading, writing, patching, searching, and transferring files. All paths are inside the container.

## writeFile [#writefile]

Write a UTF-8 string to a path inside the container. Creates parent directories automatically.

```ts
await sb.writeFile("/workspace/src/index.ts", 'export const VERSION = "1.0.0";\n');
await sb.writeFile("/tmp/config.json", JSON.stringify({ debug: true }));
```

## readFile [#readfile]

Read a file from the container as a UTF-8 string:

```ts
const source = await sb.readFile("/workspace/src/index.ts");
console.log(source);

// Use the content in subsequent steps
const config = JSON.parse(await sb.readFile("/tmp/config.json"));
```

## moveFile [#movefile]

Move or rename a file inside the container:

```ts
await sb.exec("mkdir -p /workspace/dist");
await sb.moveFile("/workspace/src/index.ts", "/workspace/dist/index.ts");
```

## deleteFile [#deletefile]

Delete a file from the container:

```ts
await sb.deleteFile("/workspace/src/util.ts");
```

## searchFiles [#searchfiles]

Search for files matching a glob pattern. Returns an array of matching paths:

```ts
const tsFiles = await sb.searchFiles("*.ts", "/workspace/src");
console.log(tsFiles);
// ["/workspace/src/index.ts", "/workspace/src/util.ts"]
```

The second argument is the base path to search from. Defaults to `/` if omitted.

## listDirectory [#listdirectory]

List entries in a directory. Returns metadata for each entry:

```ts
const entries = await sb.listDirectory("/workspace/dist");
console.log(entries.map((e) => e.path));
// ["/workspace/dist/index.ts"]

// Control recursion depth
const all = await sb.listDirectory("/workspace", { depth: 3 });
```

## createDirectory [#createdirectory]

Create a directory (and all parents) inside the container without running `exec`:

```ts
await sb.createDirectory("/workspace/src");
await sb.createDirectory("/workspace/dist");
```

## deleteDirectory [#deletedirectory]

Delete a directory from the container:

```ts
await sb.deleteDirectory("/workspace/tmp");
```

## getFileInfo [#getfileinfo]

Return metadata for a file or directory — size, type, mode, owner, and timestamps:

```ts
const info = await sb.getFileInfo("/workspace/src/index.ts");
console.log(info.size); // bytes
console.log(info.type); // "file" | "directory" | "symlink"
console.log(info.mode); // unix permission bits
console.log(info.modified_at); // ISO timestamp
```

## replaceInFiles [#replaceinfiles]

Replace a substring in one or more files in a single API call. More efficient than `readFile` → string replace → `writeFile` for targeted edits:

```ts
await sb.replaceInFiles([
  { path: "/app/config.json", old: "localhost", new: "0.0.0.0" },
  { path: "/app/src/version.ts", old: "0.0.0", new: "1.2.3" },
]);
```

## transfer [#transfer]

Copy a file from this sandbox into another sandbox. Useful after `fork()` to move results between containers:

```ts
const fork = await sb.fork("after-build");

await fork.exec("npm run build");
await fork.transfer("/app/dist/bundle.js", sb); // copy back to original

await fork.close();
```

## Pattern: patch a file [#pattern-patch-a-file]

Use `replaceInFiles` for in-place text replacements — no `exec`/`sed` needed:

```ts
await sb.writeFile("/workspace/src/index.ts", 'export const VERSION = "0.0.0";\n');
await sb.replaceInFiles([{ path: "/workspace/src/index.ts", old: "0.0.0", new: "1.2.3" }]);
const patched = await sb.readFile("/workspace/src/index.ts");
console.log(patched.trim()); // 'export const VERSION = "1.2.3";'
```

## Pattern: write a generated script and run it [#pattern-write-a-generated-script-and-run-it]

```ts
const script = `
import sys
print(f"Python {sys.version.split()[0]}")
`.trim();

await sb.writeFile("/tmp/script.py", script);
await sb.exec("python3 /tmp/script.py").pipe(process.stdout);
```

## File ops in the workflow builder [#file-ops-in-the-workflow-builder]

`writeFile`, `readFile`, `deleteFile`, and `moveFile` are available in `SandboxBuilder` for use in `@alineo-labs/workflow` (`createDirectory`, `deleteDirectory`, `getFileInfo`, `replaceInFiles`, `searchFiles`, `listDirectory`, and `transfer` are not queueable — call them directly on a `SandboxHandle` outside the workflow builder). `readFile` takes an extra `as` argument to store the result in `vars`:

```ts
await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.writeFile("/tmp/hello.txt", "hello\n");
    sb.exec("cat /tmp/hello.txt").pipe(process.stdout);
    sb.readFile("/tmp/hello.txt", "greeting"); // → vars["greeting"]
    sb.moveFile("/tmp/hello.txt", "/tmp/moved.txt");
    sb.deleteFile("/tmp/moved.txt");
  })
  .result();
```


---

# Building Workflows
URL: /docs/core/building

Practical guides for each step type and capability in the builder API.



<Cards>
  <Card href="/docs/core/building/exec" title="exec & execCode" description="Run shell commands and code in the sandbox interpreter." />

  <Card href="/docs/core/building/file-ops" title="File operations" description="Write, read, search, and manage files inside the container." />

  <Card href="/docs/core/building/control-flow" title="Control flow" description="retry, when, forEach, and parallel — composable within a sandbox." />

  <Card href="/docs/core/building/snapshots" title="Snapshots & replay" description="Checkpoint the container state and replay from it." />
</Cards>


---

# Snapshots & replay
URL: /docs/core/building/snapshots

Checkpoint the container filesystem with checkpoint() and restore from it with client.resume().



Snapshots let you capture the container's state mid-run and restore from that point later. This is useful for expensive setup steps (like installing dependencies) that don't need to repeat on every run.

## Taking a checkpoint [#taking-a-checkpoint]

`sb.checkpoint(name?)` snapshots the container's filesystem, waits until the snapshot is ready, and writes a `checkpoint_created` event to the ledger:

```ts
const sb = await client.sandbox({
  image: "python:3.11-slim",
  resources: { cpu: "1", memory: "512Mi" },
  name: "snapshot-demo",
});

try {
  await sb.exec("pip install -q requests && echo 'installed'").pipe(process.stdout);
  await sb.checkpoint("after-install");

  await sb.writeFile("/tmp/script.py", `import requests; print(requests.__version__)`);
  await sb.exec("python3 /tmp/script.py").pipe(process.stdout);
} finally {
  await sb.close();
}
```

The optional `name` argument is a tag stored in the ledger. It can be used to resume from a specific checkpoint by name — see [Named checkpoints](/docs/core/patterns/named-checkpoints).

## Resuming from a checkpoint [#resuming-from-a-checkpoint]

`client.resume(sandboxId)` reads the ledger, finds the last `checkpoint_created` event, restores a new container from that snapshot, and returns a `SandboxHandle` object with a replay cache.

```ts
const originalSandboxId = sb.sandboxId; // capture before close

// ... later ...

const sbResume = await client.resume(originalSandboxId);

try {
  // This exec is replayed from cache — returns instantly without running
  const { stdout } = await sbResume.exec("pip install -q requests && echo 'installed'");
  console.log("(replayed from cache):", stdout.trim());

  // This exec actually runs on the restored container
  await sbResume.writeFile("/tmp/script.py", `import sys; print(sys.version)`);
  await sbResume.exec("python3 /tmp/script.py").pipe(process.stdout);
} finally {
  await sbResume.close();
}
```

The resumed sandbox has a new `sandboxId` (it's a new container). The `name` stays the same.

## How replay works [#how-replay-works]

When you call `client.resume(sandboxId)`:

1. The ledger is read to find the last checkpoint
2. A new container is created from the snapshot
3. All execs that completed **before** the checkpoint are loaded into a replay cache, keyed by sequence number
4. On the resumed sandbox, execs with sequence numbers in the cache return the cached result immediately — no round-trip to the container
5. Execs with sequence numbers **after** the last cached exec run live on the new container

```
Original:                          Resumed:
  exec #1: pip install → logged    exec #1: pip install → cache hit (instant)
  checkpoint()         → snapshot  exec #2: python3 ... → runs live
  exec #2: python3 ... → logged
```

## Checkpoint in the workflow builder [#checkpoint-in-the-workflow-builder]

`sb.checkpoint()` is also available in `SandboxBuilder`:

```ts
await workflow(client)
  .sandbox({ image: "python:3.11-slim", resources: { cpu: "1", memory: "512Mi" } }, (sb) => {
    sb.exec("pip install -q requests");
    sb.checkpoint("after-install");
    sb.exec("python3 script.py");
  })
  .pipe(process.stdout);
```

## Forking a live sandbox [#forking-a-live-sandbox]

To create an independent copy without closing the original, use `sb.fork()` — it snapshots and immediately returns a new live sandbox. See [Forking sandboxes](/docs/core/patterns/fork).

## Limitations [#limitations]

* `client.resume()` requires at least one checkpoint in the session's ledger — it throws if none exists.
* Snapshots are managed by OpenSandbox. Availability depends on your server's storage configuration.
* Only the container filesystem is snapshotted. In-flight SSE streams and environment state are not captured.


---

# Credentials
URL: /docs/core/concepts/credentials

Register credentials that get injected into outbound requests without the sandbox process ever holding them.



Sandboxes often need to call an authenticated API — GitHub, Slack, an internal service. The obvious way is to pass a token through `env`, but that puts it somewhere any code running in the sandbox can read, log, or accidentally echo back to you — including code an agent wrote itself.

`sb.credentials.set()` registers a credential and a rule for where it applies. From then on, matching outbound requests get the credential injected automatically, at the network layer — the value itself never enters the container's filesystem or environment.

## Enabling it [#enabling-it]

Credential injection rides on the same opt-in egress layer as [network policy](/docs/core/concepts/network-policy) — pass `credentialProxy: true` when creating the sandbox:

```ts
const sb = await client.sandbox({
  image: "node:22",
  resources: { cpu: "500m", memory: "512Mi" },
  networkPolicy: { defaultAction: "allow", egress: [] },
  credentialProxy: true,
});
```

A sandbox created without `networkPolicy`/`credentialProxy` has ordinary, unrestricted egress — nothing here applies to it, and `sb.credentials.*` throws if you call it on one. `networkPolicy` itself is a separate concern (which hosts the sandbox may reach at all, allow/deny style — see [Network policy](/docs/core/concepts/network-policy)); `credentialProxy` is what makes injection available. Setting `defaultAction: "allow"` with an empty `egress` list, as above, keeps egress wide open while turning injection on — tighten `egress` if you also want to restrict which hosts are reachable at all. `credentialProxy` also needs the server on `egress.mode = "dns+nft"` (the `alineo init` default).

## Registering a credential [#registering-a-credential]

```ts
await sb.credentials.set("github", process.env.GH_TOKEN!, {
  host: "api.github.com",
  injection: { type: "header", name: "Authorization" },
});
```

Any request the sandbox makes to `api.github.com` now gets that header added automatically — a plain `curl https://api.github.com/user` with no `Authorization` header of its own comes back authenticated. Requests to any other host are untouched. Run `env` inside the sandbox and the token isn't there.

| `CredentialBinding` field | Type                  | Description                                                 |
| ------------------------- | --------------------- | ----------------------------------------------------------- |
| `host`                    | `string`              | FQDN the credential applies to                              |
| `pathPrefix`              | `string` (optional)   | Narrows the binding to requests whose path starts with this |
| `injection`               | `CredentialInjection` | Where the value goes — see below                            |

## Injection modes [#injection-modes]

```ts
type CredentialInjection =
  | { type: "header"; name: string }
  | { type: "substitution"; placeholder: string; in: Array<"path" | "query" | "header" | "body"> };
```

### `header` (recommended) [#header-recommended]

Adds `name: <value>` to every matching outbound request. This is the default and the right choice for anything that takes a bearer token or API-key header.

```ts
injection: { type: "header", name: "Authorization" }
```

### `substitution` [#substitution]

For APIs that want the secret in the URL or body rather than a header. The sidecar replaces **every literal occurrence** of `placeholder` in the listed request surfaces with the real value.

```ts
await sb.credentials.set("openai", process.env.OPENAI_API_KEY!, {
  host: "api.example.com",
  injection: { type: "substitution", placeholder: "__API_KEY__", in: ["query"] },
});
```

The outbound request **must already contain `placeholder` verbatim** — the sidecar only substitutes, it doesn't append. Put it in the base URL you give your client:

```ts
// inside the sandbox
fetch("https://api.example.com/v1/data?key=__API_KEY__");
// leaves the sandbox as ...?key=<real value>
```

`sb.credentials.listBindings()` is lossy for substitution bindings (the vault doesn't echo the substitution config back), but `resume()` / `fork()` recover the full shape from the ledger.

<Callout type="warn" title="Migrating from query / path injection">
  The old `{ type: "query"; param }` and `{ type: "path"; segment }` shapes were removed in 0.4.0 (they only ever threw `UnsupportedInjectionError`). Replace `{ type: "query"; param: "k" }` with `{ type: "substitution"; placeholder: "__CRED__"; in: ["query"] }` and add `?k=__CRED__` to the request URL.
</Callout>

## Removing a credential [#removing-a-credential]

```ts
await sb.credentials.remove("github");
```

Requests to that host go out unauthenticated from then on.

## Where the value comes from — `source` [#where-the-value-comes-from--source]

`set()` takes an optional fourth argument describing how to re-derive the value later. It's not needed for a sandbox you register a credential on and close normally — only `resume()` and `fork()` (below) ever read it:

```ts
await sb.credentials.set(
  "github",
  process.env.GH_TOKEN!,
  { host: "api.github.com", injection: { type: "header", name: "Authorization" } },
  { type: "env", varName: "GH_TOKEN" },
);
```

* `{ type: "env", varName }` — re-read from `process.env` automatically, no extra code needed.
* `{ type: "external" }` (the default if you omit `source` entirely) — alineo has no way to reproduce this value on its own (a one-time minted token, something generated at call time). Resuming or forking a sandbox with one of these requires you to supply it explicitly — see below.

## resume() and fork() never drop a credential silently [#resume-and-fork-never-drop-a-credential-silently]

If a sandbox with bound credentials is resumed or forked and one of them can't be resolved, alineo throws rather than quietly continuing without it — a credential that silently stops being injected is a worse failure mode than a loud one.

```ts
const sb = await client.resume(sandboxId, {
  resolveCredential: (name, source) => {
    if (name === "one-time-token") return mintFreshToken();
    return undefined; // let anything else fall through to the env-var default, if it has one
  },
});
```

Anything registered with `{ type: "env" }` resolves on its own. Anything else needs `resolveCredential` to return a value for it, or the call throws naming exactly which credential it couldn't resolve.

`sb.fork()` carries over the parent's own bound credentials to the child automatically — the child starts out able to make the same authenticated requests the parent could, with no re-registration needed. The same resolution rules apply: pass `resolveCredential` to `fork()` if any of the carried-over credentials need one.

## Custom credential backends [#custom-credential-backends]

`sb.credentials.*` is backed by a `CredentialBroker` interface (the same shape as `IStorageAdapter` — see [Storage adapters](/docs/core/concepts/storage-adapters)). `@alineo-labs/vault`'s `OpenSandboxCredentialBroker` is the default, wired up automatically — nothing to configure for the common case. Pass `SandboxClientOptions.credentialBroker` to use a different implementation.


---

# Environments
URL: /docs/core/concepts/environments

Define a named setup recipe once, snapshot it, and spawn cheap isolated sandboxes from it on demand.



An **environment** is a named sandbox configuration with a setup recipe. It's built once — installs packages, writes config, compiles assets — snapshots the result, and restores from that snapshot on every subsequent call. Setup never runs again unless you explicitly rebuild.

Each sandbox spawned from an environment is fully isolated. Changes in one do not affect others or the snapshot.

## Defining an environment [#defining-an-environment]

`client.environment(name, opts)` returns an `Environment` object. No I/O happens at this point.

```ts
const env = client.environment("python-data-science", {
  image: "debian:bookworm-slim",
  resources: { cpu: "500m", memory: "512Mi" },
  setup: async (sb) => {
    await sb.exec(
      "apt-get update -qq && apt-get install -y python3-pip --no-install-recommends -q",
    );
    await sb.exec("pip install --quiet numpy pandas matplotlib");
  },
});
```

| Option      | Type                                            | Description                                                                       |
| ----------- | ----------------------------------------------- | --------------------------------------------------------------------------------- |
| `image`     | `string \| { uri, auth? }`                      | Container image. Same format as `SandboxOptions.image`.                           |
| `resources` | `{ cpu: string; memory: string; gpu?: string }` | Applied to both the build sandbox and each spawned sandbox.                       |
| `setup`     | `(sb: SandboxHandle) => Promise<void>`          | Runs once to configure the environment.                                           |
| `shell`     | `string`                                        | Shell binary for all `exec()` calls in this environment. Defaults to `"/bin/sh"`. |

## Spawning a sandbox [#spawning-a-sandbox]

```ts
const sb = await env.sandbox();
try {
  await sb.exec("python3 -c 'import pandas; print(pandas.__version__)'").pipe(process.stdout);
} finally {
  await sb.close();
}
```

The first call builds and snapshots the environment. Every subsequent call restores from the cached snapshot. Concurrent first calls are safe — setup runs exactly once even if multiple callers race.

`env.sandbox(extra?)` accepts additional per-spawn options:

```ts
const sb = await env.sandbox({
  env: { PYTHONPATH: "/app", DEBUG: "1" },
  hooks: otelHooks(tracer),
  shell: "/bin/bash", // overrides EnvironmentOptions.shell for this sandbox only
});
```

| Option  | Type                     | Description                                                                     |
| ------- | ------------------------ | ------------------------------------------------------------------------------- |
| `env`   | `Record<string, string>` | Environment variables set at container startup.                                 |
| `hooks` | `SandboxHooks`           | Observability hooks (e.g. `otelHooks(tracer)` from `@alineo-labs/otel`).        |
| `shell` | `string`                 | Shell override for this sandbox only. Falls back to `EnvironmentOptions.shell`. |

## Rebuilding after setup changes [#rebuilding-after-setup-changes]

Call `env.rebuild()` to force a fresh build, discarding the cached snapshot:

```ts
await env.rebuild();
```

There is no automatic invalidation. Call `rebuild()` explicitly whenever your setup script changes.

## Inspecting an environment [#inspecting-an-environment]

```ts
const info = await env.info();
// → { name, snapshotId, image, builtAt } | null
```

Returns `null` if the environment has never been built. `builtAt` is a Unix timestamp in milliseconds.

## Managing environments [#managing-environments]

```ts
// List all cached environments
const envs = await client.environments.list();
// → [{ name, snapshotId, image, builtAt }, ...]

// Remove the ledger record for an environment
await client.environments.delete("python-data-science");
```

`delete` removes the ledger record only — it does not delete the server-side snapshot. Cleaning up orphaned snapshots is handled by OpenSandbox's TTL policy or via the OpenSandbox API directly.

## Example [#example]

```ts
import { Sandbox } from "@alineo-labs/sandbox";
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new SQLiteAdapter("./ledger.db"),
});

const env = client.environment("node-ci", {
  image: "debian:bookworm-slim",
  resources: { cpu: "1", memory: "1Gi" },
  setup: async (sb) => {
    await sb.exec("apt-get update -qq && apt-get install -y nodejs npm -q");
    await sb.exec("npm install -g typescript ts-node");
  },
});

// Two isolated CI jobs from the same environment
const [sb1, sb2] = await Promise.all([env.sandbox(), env.sandbox()]);

try {
  await Promise.all([
    sb1.exec("node --version").pipe(process.stdout),
    sb2.exec("tsc --version").pipe(process.stdout),
  ]);
} finally {
  await Promise.all([sb1.close(), sb2.close()]);
}
```

## Limitations [#limitations]

* Images must have `sh` and `base64`. Both are present in Debian, Ubuntu, and Alpine. Pass `shell: "/bin/bash"` if your setup commands require bash-specific syntax.
* There is no automatic invalidation. If your setup function changes, the snapshot is stale until you call `env.rebuild()`.
* Snapshots live on the OpenSandbox server. If the server prunes a snapshot (TTL), `env.sandbox()` detects the missing snapshot via `getSnapshot` and rebuilds automatically.
* Concurrent `rebuild()` calls are not deduplicated. For production use, call `rebuild()` from a single coordinated process such as a deploy script.


---

# Event stream
URL: /docs/core/concepts/event-stream

How ExecHandle streams stdout from the sandbox in real time — pipe, async generator, and await.



`sb.exec()` and `sb.execCode()` both return an `ExecHandle`. Under the hood, execd sends execution output as an SSE stream. `ExecHandle` wraps that stream and exposes three consumption modes.

## Modes [#modes]

### Pipe [#pipe]

Send stdout directly to any writable with a `write(chunk: string)` method:

```ts
await sb.exec("npm run build").pipe(process.stdout);
await sb.exec("npm test").pipe(process.stderr);
```

This is the most common mode. Output flows in real time as the command runs.

### Async generator [#async-generator]

Iterate stdout chunks one at a time:

```ts
for await (const chunk of sb.exec("npm run build").stdout()) {
  process.stdout.write(chunk);
  // or process each chunk (e.g. parse log lines)
}
```

Use this when you want to inspect or transform output as it arrives.

### Await [#await]

Await the handle directly to get the full result after the command completes:

```ts
const { stdout, stderr, exitCode } = await sb.exec("node --version");
```

This buffers all stdout in memory and resolves once the command exits. Use when you need the full output as a string.

### result() [#result]

`.result()` is an explicit promise form that resolves to `{ stdout, stderr, exitCode }`:

```ts
const handle = sb.exec("npm test");
// ... do something else ...
const { exitCode } = await handle.result();
```

## ExecResult type [#execresult-type]

```ts
interface ExecResult {
  stdout: string; // full stdout as a string
  stderr: string; // full stderr as a string
  exitCode: number; // process exit code
}
```

## Strict mode [#strict-mode]

By default, `exec()` throws `CommandError` if the exit code is non-zero (strict mode). Pass `{ strict: false }` to get the `ExecResult` instead:

```ts
const { exitCode } = await sb.exec("test -f package.json", { strict: false });
if (exitCode !== 0) {
  console.log("no package.json");
}
```

## What gets streamed [#what-gets-streamed]

Only stdout is streamed via `pipe()` and the `stdout()` generator. Stderr is buffered separately and available via `(await handle).stderr` or `.result()`.

The underlying SSE stream from execd also carries exit code information, which `ExecHandle` uses to populate `exitCode` in the result.


---

# Concepts
URL: /docs/core/concepts

The core ideas behind the sandbox client — what things are and how they relate.



<Cards>
  <Card href="/docs/core/concepts/workflows" title="Workflows" description="What a workflow is, how it's defined, and how it runs." />

  <Card href="/docs/core/concepts/sandboxes" title="Sandboxes" description="Isolated Docker containers managed by OpenSandbox." />

  <Card href="/docs/core/concepts/environments" title="Environments" description="Define a setup recipe once, snapshot it, and spawn isolated sandboxes from it on demand." />

  <Card href="/docs/core/concepts/steps" title="Steps" description="The unit of work — leaf steps and control-flow steps." />

  <Card href="/docs/core/concepts/refs-and-state" title="Refs & state" description="How to capture step output and thread values through a workflow." />

  <Card href="/docs/core/concepts/event-stream" title="Event stream" description="The AsyncIterable that streams events as steps execute." />

  <Card href="/docs/core/concepts/storage-adapters" title="Storage adapters" description="How the ledger persists every event for durability and replay." />

  <Card href="/docs/core/concepts/credentials" title="Credentials" description="Inject credentials into outbound requests without the sandbox process ever holding them." />
</Cards>


---

# Network policy
URL: /docs/core/concepts/network-policy

Control which hosts a sandbox may reach — set an allow/deny policy at creation, or change it at runtime with sb.egress.*.



By default a sandbox has ordinary, unrestricted outbound network access. Pass a `networkPolicy` to `client.sandbox()` and an **egress sidecar** is attached to the container: every DNS query and (in `dns+nft` mode) every raw-IP connection is checked against your rules before it leaves.

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
  networkPolicy: {
    defaultAction: "deny",
    egress: [
      { action: "allow", target: "api.github.com" },
      { action: "allow", target: "*.npmjs.org" },
    ],
  },
});
```

<Callout title="Server requirement">
  The egress sidecar needs the OpenSandbox server to have `egress.image` configured. `alineo init`
  sets this up by default (`opensandbox/egress:v1.1.7`, `mode = "dns+nft"`). On an older config or a
  `uvx opensandbox-server` host, add an `[egress]` section to `~/.config/alineo/server.toml` (or
  `~/.sandbox.toml`) and restart the server. Omit `networkPolicy` entirely and none of this applies
  — no sidecar is attached.
</Callout>

## NetworkPolicy [#networkpolicy]

| Field           | Type                | Description                                                                   |
| --------------- | ------------------- | ----------------------------------------------------------------------------- |
| `defaultAction` | `"allow" \| "deny"` | What to do when no rule matches. Defaults to `"deny"` server-side if omitted. |
| `egress`        | `NetworkRule[]`     | Ordered allow/deny rules.                                                     |

Each `NetworkRule` is `{ action: "allow" | "deny"; target: string }`.

### Rule targets [#rule-targets]

`target` is one of:

* **An FQDN** — `"api.github.com"`. Matched against the DNS query name.
* **A wildcard domain** — `"*.openai.com"`. The `*.` prefix is the sidecar's only wildcard form (it matches one or more leading labels).
* **A bare IPv4/IPv6 address** — `"10.0.0.5"`, `"2606:4700::1111"`.
* **A CIDR block** — `"10.0.0.0/8"`, `"fd00::/8"`.

IP and CIDR rules are enforced at the **nftables layer**, so they only take effect when the server runs `egress.mode = "dns+nft"`, and they gate **raw-IP egress only**. A CIDR rule does *not* authorize resolving a *domain* that happens to point into that range — for reach-by-name you still need a domain rule.

A malformed `target` (a URL, whitespace, a space-containing string) throws `SandboxClientError` locally before any server round-trip. The same check is exported as `isValidEgressTarget` from `@alineo-labs/opensandbox`.

## Changing the policy at runtime [#changing-the-policy-at-runtime]

`sb.egress.*` adjusts a **running** sandbox's policy through its sidecar. The change applies immediately — no restart, no new sandbox.

```ts
// Allow a host on a sandbox that's already running:
await sb.egress.patch([{ action: "allow", target: "example.com" }]);

// Revoke it — the host is blocked again:
await sb.egress.delete(["example.com"]);

// Read back the live policy from the sidecar:
const { policy } = await sb.egress.get();
```

| Method                      | Behavior                                                                                                                                    |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `sb.egress.patch(rules)`    | Merge rules in. An incoming rule **replaces** any existing rule with the same `target`; every other rule and `defaultAction` are untouched. |
| `sb.egress.delete(targets)` | Remove rules by `target`. Unknown targets are silently ignored.                                                                             |
| `sb.egress.get()`           | Returns the sidecar's status envelope: `{ status?, mode?, enforcementMode?, policy? }`.                                                     |

These only work on a sandbox created **with** a `networkPolicy` — without one there is no sidecar and the calls error.

## Persistence across resume and fork [#persistence-across-resume-and-fork]

Runtime `sb.egress.*` changes are **sidecar-local**: they don't survive the sidecar restarting and OpenSandbox's snapshot/checkpoint doesn't capture them. Every `patch` / `delete` is written to the ledger (`EgressRuleAdded` / `EgressRuleRemoved`), and `Sandbox.resume()` folds whatever is still live back into the resumed sandbox's boot policy — so a still-wanted allowance is re-applied automatically.

`sb.fork()` does **not** carry runtime egress rules. A fork is a fresh branch and starts with a wide-open `defaultAction: "allow"` policy.

## Relationship to credential injection [#relationship-to-credential-injection]

`networkPolicy` (which hosts are reachable) and `credentialProxy` (transparent credential injection) are separate concerns that ride the same sidecar. `credentialProxy: true` **requires** `networkPolicy` to also be set. See [Credentials](/docs/core/concepts/credentials).

For an agent that should hold a specific host behind a **human decision** before it's reachable, see [Permission gate](/docs/agent/getting-started/permissions#holding-network-egress-for-approval).


---

# Refs & state
URL: /docs/core/concepts/refs-and-state

Capture exec output and thread values between steps using ExecResult and the workflow builder's vars.



There's no special `Ref<T>` type. Output from one step is just a TypeScript variable — you capture it from the `ExecResult` and pass it to the next step however you like.

## Capturing stdout [#capturing-stdout]

`await sb.exec(cmd)` returns `{ stdout, stderr, exitCode }`. Use `stdout` directly in subsequent commands:

```ts
const sb = await client.sandbox({
  image: "node:20-slim",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  const { stdout: nodeVersion } = await sb.exec('node -e "process.stdout.write(process.version)"');

  // Use the captured value in the next exec
  await sb.exec(`echo "Running on Node ${nodeVersion.trim()}"`).pipe(process.stdout);

  // Or write it to a file
  await sb.writeFile(
    "/tmp/info.json",
    JSON.stringify({ node: nodeVersion.trim(), capturedAt: new Date().toISOString() }),
  );
} finally {
  await sb.close();
}
```

## Reading files as state [#reading-files-as-state]

`sb.readFile(path)` returns the file content as a string:

```ts
await sb.exec("node -e \"require('fs').writeFileSync('/tmp/version.txt', process.version)\"");
const version = await sb.readFile("/tmp/version.txt");
console.log(version.trim()); // "v20.x.x"
```

## State in the workflow builder [#state-in-the-workflow-builder]

When using `@alineo-labs/workflow`, the `SandboxBuilder` doesn't return values from `exec()` — steps are queued, not executed yet. Use `readFile(path, as)` to capture a file into the `vars` map, then read from `vars` after the workflow resolves:

```ts
import { workflow } from "@alineo-labs/workflow";

const { vars } = await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.exec("node --version > /tmp/version.txt");
    sb.readFile("/tmp/version.txt", "nodeVersion"); // captured into vars["nodeVersion"]
  })
  .result();

console.log(vars.nodeVersion); // "v22.x.x\n"
```

The `when` predicate also receives runtime state:

```ts
sb.exec("test -f /etc/hostname", { strict: false });
sb.when(
  (ctx) => ctx.exitCode === 0, // ctx.exitCode, ctx.stdout, ctx.vars
  (sb) => sb.exec("echo exists"),
  (sb) => sb.exec("echo missing"),
);
```

`ctx.stdout` is the accumulated stdout from all execs in the sandbox so far. `ctx.vars` holds anything captured via `readFile(..., as)`.

## Passing state between sandboxes [#passing-state-between-sandboxes]

In `.sequence()`, each stage receives the previous stage's `WorkflowResult`:

```ts
await workflow(client)
  .sequence([
    {
      image: "node:22",
      name: "build",
      resources: { cpu: "1", memory: "512Mi" },
      run: (sb) => {
        sb.exec("npm run build 2>&1 | tee /tmp/build.log");
        sb.readFile("/tmp/build.log", "buildLog");
      },
    },
    {
      image: "ubuntu:22.04",
      name: "deploy",
      resources: { cpu: "500m", memory: "256Mi" },
      run: (sb, prev) => {
        // prev.vars.buildLog from the first sandbox
        sb.exec(`echo "Build output lines: $(echo '${prev?.vars.buildLog}' | wc -l)"`);
      },
    },
  ])
  .pipe(process.stdout);
```


---

# Sandboxes
URL: /docs/core/concepts/sandboxes

Isolated Docker containers managed by OpenSandbox — how they're created, configured, and closed.



A sandbox is a Docker container managed by OpenSandbox. `client.sandbox()` creates one and returns a live `SandboxHandle` object. The container persists until `sb.close()` is called.

## Creating a sandbox [#creating-a-sandbox]

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "512Mi" },
  name: "my-sandbox",
});
```

`client.sandbox()` does three things:

1. Calls the OpenSandbox control API to create the container
2. Waits until the container reaches `Running` state
3. Returns the `SandboxHandle` object

## SandboxOptions [#sandboxoptions]

| Option            | Type                                            | Description                                                                                                                                                        |
| ----------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `image`           | `string \| { uri, auth? }`                      | Container image. String form: `"ubuntu:22.04"`. Object form for private registries: `{ uri: "ghcr.io/org/image", auth: { username, password } }`                   |
| `resources`       | `{ cpu: string; memory: string; gpu?: string }` | Resource limits. Required. `cpu` is a Kubernetes-style string like `"500m"` or `"2"`. `memory` is `"256Mi"`, `"1Gi"`, etc.                                         |
| `env`             | `Record<string, string>`                        | Environment variables set in the container at startup                                                                                                              |
| `metadata`        | `Record<string, string>`                        | Arbitrary key-value labels attached to the sandbox (e.g. `{ runId: "ci-42" }`)                                                                                     |
| `name`            | `string`                                        | User-provided name for the run — used as the ledger key. Auto-generated if omitted.                                                                                |
| `timeout`         | `number`                                        | Sandbox lifetime in seconds. Defaults to the OpenSandbox server's default.                                                                                         |
| `hooks`           | `SandboxHooks`                                  | Lifecycle callbacks for observability. See [Observability](/docs/core/patterns/observability).                                                                     |
| `networkPolicy`   | `{ defaultAction, egress }`                     | Optional outbound network policy — which hosts the sandbox may reach. Required to use `credentialProxy`. See [Network policy](/docs/core/concepts/network-policy). |
| `credentialProxy` | `boolean`                                       | Opt-in to credential injection via `sb.credentials.*`. See [Credentials](/docs/core/concepts/credentials).                                                         |

## Sandbox lifecycle [#sandbox-lifecycle]

Always use `try/finally` to ensure the container is deleted even if a step throws:

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
});

try {
  await sb.exec("npm ci");
  await sb.exec("npm test").pipe(process.stdout);
} finally {
  await sb.close(); // deletes the container, writes sandbox_closed to ledger
}
```

`close()` is idempotent — subsequent calls are no-ops.

## SandboxHandle properties [#sandboxhandle-properties]

```ts
sb.sandboxId; // OpenSandbox container ID — also the unique ledger key
sb.name; // User-provided name, or "sandbox-<short-id>" if omitted
```

## Multiple sandboxes [#multiple-sandboxes]

Each `client.sandbox()` call creates an independent container. Hold them as separate variables:

```ts
const sbA = await client.sandbox({ image: "node:22", resources: { cpu: "500m", memory: "512Mi" } });
const sbB = await client.sandbox({
  image: "python:3.11",
  resources: { cpu: "500m", memory: "512Mi" },
});

try {
  await sbA.exec("node --version").pipe(process.stdout);
  await sbB.exec("python3 --version").pipe(process.stdout);
} finally {
  await sbA.close();
  await sbB.close();
}
```

## Concurrency limits [#concurrency-limits]

`SandboxClientOptions.maxConcurrency` caps how many sandboxes may be active at once. When at capacity, `client.sandbox()` awaits until a slot is free (released by `sb.close()`):

```ts
const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new SQLiteAdapter("./alineo.db"),
  maxConcurrency: 5, // at most 5 active sandboxes
});
```


---

# Steps
URL: /docs/core/concepts/steps

What you can do with a SandboxHandle — exec, execCode, file operations, checkpoint, and control flow.



Once you have a `SandboxHandle`, you call methods on it to do work. Each call is a "step" — it runs inside the container and its result is logged to the ledger.

## Shell commands — exec() [#shell-commands--exec]

`sb.exec(cmd)` runs a shell command and returns an `ExecHandle`.

```ts
// Await for the full result
const { stdout, stderr, exitCode } = await sb.exec("node --version");

// Pipe stdout in real time
await sb.exec("npm run build").pipe(process.stdout);

// Non-zero exit does not throw by default when strict: false
const { exitCode } = await sb.exec("test -f package.json", { strict: false });
```

By default, `exec()` throws `CommandError` on non-zero exit (`strict: true`). Pass `{ strict: false }` to get the exit code instead of an exception.

## Code interpreter — execCode() [#code-interpreter--execcode]

`sb.execCode(code)` runs code via the sandbox's code interpreter (Python, JS, etc.) using the execd `/code` endpoint.

```ts
// Stateless — isolated context each time
await sb.execCode(`print("hello")`).pipe(process.stdout);

// Stateful — variables persist across calls sharing the same context.
// Contexts must be created first via createCodeContext() — you can't hand-roll one.
const ctx = await sb.createCodeContext(CodeLanguage.Python);

await sb.execCode(`data = [1, 2, 3]`, { context: ctx });
await sb.execCode(`print(sum(data))`, { context: ctx }).pipe(process.stdout); // 6
```

Import `CodeLanguage` from `@alineo-labs/sandbox`:

```ts
import { CodeLanguage } from "@alineo-labs/sandbox";
```

Requires a code-interpreter image (e.g. `opensandbox/code-interpreter`).

## File operations [#file-operations]

| Method                           | Description                                                   |
| -------------------------------- | ------------------------------------------------------------- |
| `sb.writeFile(path, content)`    | Write a UTF-8 string into the container                       |
| `sb.readFile(path)`              | Read a file as a UTF-8 string                                 |
| `sb.moveFile(from, to)`          | Move or rename a file inside the container                    |
| `sb.deleteFile(path)`            | Delete a file from the container                              |
| `sb.searchFiles(pattern, path?)` | Search for files matching a glob; returns an array of matches |
| `sb.listDirectory(path, opts?)`  | List directory entries; `opts.depth` controls recursion depth |

See [File operations](/docs/core/building/file-ops) for full examples.

## Checkpoints [#checkpoints]

`sb.checkpoint(name?)` snapshots the container's filesystem and writes the snapshot ID to the ledger. Pass an optional label for easier identification.

```ts
await sb.exec("npm ci");
await sb.checkpoint("after-install");
await sb.exec("npm test");
```

Use `client.resume(sandboxId)` to restore from the last checkpoint. See [Snapshots & replay](/docs/core/building/snapshots).

## Control flow (workflow builder) [#control-flow-workflow-builder]

The `@alineo-labs/workflow` package adds queued control-flow steps: `retry`, `when`, `forEach`. These are only available in the workflow builder (`SandboxBuilder`), not on the `SandboxHandle` class directly.

```ts
import { workflow } from "@alineo-labs/workflow";

await workflow(client)
  .sandbox({ image: "ubuntu:22.04", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.retry(3, (sb) => sb.exec("flaky-command"), { backoff: "exponential" });
    sb.when(
      (ctx) => ctx.exitCode === 0,
      (sb) => sb.exec("echo success"),
      (sb) => sb.exec("echo failure"),
    );
    sb.forEach(["a", "b", "c"], (sb, item) => sb.exec(`echo ${item}`));
  })
  .pipe(process.stdout);
```

See [Control flow](/docs/core/building/control-flow) for full docs.


---

# Storage adapters
URL: /docs/core/concepts/storage-adapters

How the IStorageAdapter interface persists every exec event for durability and replay.



Every live exec, checkpoint, and sandbox lifecycle event is written to a storage adapter as it happens. This makes runs durable — if your process crashes, the ledger contains everything needed to know what ran and what was captured. (Replayed execs in a resumed sandbox return cached output instantly and do not write new events — see [Checkpoint & resume](/docs/core/building/snapshots).)

## Why persistence matters [#why-persistence-matters]

The ledger enables:

* **Audit** — see which commands ran, when, and what they output
* **Resume** — `client.resume(sandboxId)` reads the ledger to rebuild replay cache and restore the container
* **Observability** — `client.sandboxes.list()` returns run history across all sessions

## Choosing an adapter [#choosing-an-adapter]

| Adapter           | Package                 | When to use                              |
| ----------------- | ----------------------- | ---------------------------------------- |
| `SQLiteAdapter`   | `@alineo-labs/sqlite`   | Local dev, single-process apps, scripts  |
| `PostgresAdapter` | `@alineo-labs/postgres` | Production, multi-process, shared ledger |

## SQLite [#sqlite]

Zero-dependency, WAL mode enabled for concurrent access:

```ts
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new SQLiteAdapter("./ledger.db"),
});
```

The file is created if it doesn't exist. WAL mode means readers don't block writers, so multiple concurrent sandbox sessions are safe. No `connect()` call is needed — see [Connect and close](#connect-and-close) below.

## Postgres [#postgres]

For production deployments where multiple processes share a ledger:

```ts
import { PostgresAdapter } from "@alineo-labs/postgres";

const client = new Sandbox({
  baseUrl: process.env.OPEN_SANDBOX_URL!,
  adapter: new PostgresAdapter(process.env.DATABASE_URL!),
});
```

## Connect and close [#connect-and-close]

You never need to call `connect()` or `close()` on the *adapter* — it initializes lazily (`CREATE TABLE IF NOT EXISTS` migrations run automatically on first use) and closes itself via a `process.on("beforeExit", ...)` hook, which only fires when the event loop drains naturally. Long-running servers never reach `beforeExit`, so the pool stays open for the process lifetime — that's expected, not a leak. (`Sandbox` itself does have its own `connect()` method, unrelated to the adapter — see [connect()](/docs/core/api-reference/alineo-client#connect) — for attaching to an already-running sandbox by ID.) Just construct the client and start using it:

```ts
const client = new Sandbox({ baseUrl: "...", adapter: new PostgresAdapter("...") });
// ... use client — migrations run on first call, no setup step needed ...
// no close() call needed; the adapter shuts down on process exit
```

## Events written to the ledger [#events-written-to-the-ledger]

| Event                | When                                                      |
| -------------------- | --------------------------------------------------------- |
| `sandbox_created`    | Container created and reached Running state               |
| `exec_start`         | `sb.exec()` or `sb.execCode()` called                     |
| `exec_event`         | Stdout/stderr chunk received from execd                   |
| `exec_complete`      | exec finished; payload has `{ seq, exitCode }`            |
| `checkpoint_created` | `sb.checkpoint()` completed; payload has `{ snapshotId }` |
| `sandbox_closed`     | `sb.close()` called                                       |

## Custom adapter [#custom-adapter]

Implement `IStorageAdapter` to use any backend. See [Custom adapter](/docs/core/adapters/custom).


---

# Workflows
URL: /docs/core/concepts/workflows

The two usage modes — direct sandbox API and the @alineo-labs/workflow lazy builder.



There are two ways to work with sandboxes. Both use the same underlying `SandboxHandle` and `ExecHandle` types.

## Direct sandbox API [#direct-sandbox-api]

You create a sandbox, call methods on it, and close it. This is the simplest and most flexible mode — you have full control over the lifecycle and can use ordinary TypeScript between steps.

```ts
import { Sandbox } from "@alineo-labs/sandbox";
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new SQLiteAdapter("./ledger.db"),
});

const sb = await client.sandbox({
  image: "node:22",
  resources: { cpu: "500m", memory: "512Mi" },
  name: "build-and-test",
});

try {
  await sb.exec("npm ci");
  const { exitCode } = await sb.exec("npm run build");
  if (exitCode === 0) {
    await sb.exec("npm test").pipe(process.stdout);
  }
} finally {
  await sb.close();
}
```

Use the direct API when:

* You need to branch on output values mid-workflow
* You want to run different commands based on external state
* Your workflow is straightforward (a few steps, no retry logic)

## Workflow builder [#workflow-builder]

`@alineo-labs/workflow` provides a lazy builder that queues operations synchronously and flushes them when `.pipe()` or `.result()` is awaited. The builder handles sandbox lifecycle (create + close) automatically.

```ts
import { workflow } from "@alineo-labs/workflow";

await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "512Mi" } }, (sb) => {
    sb.exec("npm ci");
    sb.checkpoint("after-install");
    sb.retry(3, (sb) => sb.exec("npm test"), { backoff: "exponential" });
  })
  .pipe(process.stdout);
```

Use the workflow builder when:

* You need `retry`, `when`, `forEach`, or `parallel`
* You want lifecycle managed automatically (no try/finally)
* You're building multi-sandbox pipelines with `.parallel()` or `.sequence()`

## Multi-sandbox pipelines [#multi-sandbox-pipelines]

`WorkflowBuilder` supports chaining multiple sandbox stages:

```ts
// Sequential: stage 2 starts after stage 1 finishes
await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.exec("npm run build");
  })
  .sandbox({ image: "ubuntu:22.04", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.exec("./deploy.sh");
  })
  .pipe(process.stdout);

// Parallel: all run simultaneously, results merged
await workflow(client)
  .parallel(
    [
      { image: "node:20", resources: { cpu: "500m", memory: "256Mi" } },
      { image: "node:22", resources: { cpu: "500m", memory: "256Mi" } },
      { image: "node:24", resources: { cpu: "500m", memory: "256Mi" } },
    ],
    (sb) => sb.exec("npm test"),
  )
  .pipe(process.stdout);

// Sequence: each step receives the previous step's result
await workflow(client)
  .sequence([
    {
      image: "node:22",
      name: "build",
      resources: { cpu: "500m", memory: "256Mi" },
      run: (sb) => sb.exec("npm run build"),
    },
    {
      image: "ubuntu:22.04",
      name: "deploy",
      resources: { cpu: "500m", memory: "256Mi" },
      run: (sb, prev) => sb.exec("./deploy.sh"),
    },
  ])
  .pipe(process.stdout);
```

## Getting results [#getting-results]

Both the direct API and the workflow builder give you access to captured output:

```ts
// Direct API — capture stdout from exec result
const { stdout } = await sb.exec("node --version");

// Workflow builder — readFile stores into vars
const { vars } = await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.exec("node --version > /tmp/version.txt");
    sb.readFile("/tmp/version.txt", "nodeVersion");
  })
  .result();

console.log(vars.nodeVersion); // "v22.x.x\n"
```


---

# How it works
URL: /docs/core/getting-started/how-it-works

SandboxHandle as a first-class object, ExecHandle, the durable ledger, and the @alineo-labs/workflow lazy layer.



## SandboxHandle as a first-class object [#sandboxhandle-as-a-first-class-object]

`client.sandbox()` creates a container and returns a live `SandboxHandle` object. You hold it as a variable, call methods on it, and `close()` it when done. Multiple sandboxes are just multiple variables — no special API.

```mermaid
flowchart LR
  A["client.sandbox(opts)"] --> B["SandboxHandle<br/>(you hold this)"]
  B --> C["exec() / execCode()<br/>→ ExecHandle"]
  B --> D["writeFile() / readFile()"]
  B --> E["checkpoint()<br/>snapshot the container"]
  B --> F["close()<br/>delete the container"]
```

Always wrap sandbox usage in `try/finally` so the container is cleaned up even if an exec throws:

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  await sb.exec("npm ci");
  await sb.exec("npm test").pipe(process.stdout);
} finally {
  await sb.close();
}
```

## ExecHandle [#exechandle]

`sb.exec()` returns an `ExecHandle` — a `PromiseLike<ExecResult>` with three consumption modes:

| Mode      | How                                                | When to use                                |
| --------- | -------------------------------------------------- | ------------------------------------------ |
| Await     | `const { stdout, exitCode } = await sb.exec(...)`  | You need the full result before continuing |
| Pipe      | `await sb.exec(...).pipe(process.stdout)`          | Real-time output to a writable             |
| Generator | `for await (const chunk of sb.exec(...).stdout())` | Process chunks individually                |

All three modes drive the same underlying SSE stream from execd. `.result()` resolves to `{ stdout, stderr, exitCode }` once the stream is fully consumed.

## The durable ledger [#the-durable-ledger]

Every live `exec()` call writes three events to the storage adapter (replayed execs on a resumed sandbox skip this — see [Checkpoint and resume](#checkpoint-and-resume) below):

```
exec_start    { cmd, seq }
exec_event    { seq, type: "stdout", text: "..." }  ← one per chunk
exec_complete { seq, exitCode }
```

`client.sandbox()` writes `sandbox_created`. `sb.close()` writes `sandbox_closed`.

This means every run is fully replayable from the ledger — even if the process crashes mid-exec.

## Checkpoint and resume [#checkpoint-and-resume]

`sb.checkpoint()` calls the OpenSandbox snapshot API, waits for the snapshot to become ready, and writes a `checkpoint_created` event with the snapshot ID.

`client.resume(sandboxId)` reads the ledger, finds the last checkpoint, restores a new container from the snapshot, and populates a **replay cache** from execs that completed before the checkpoint.

On the resumed sandbox, calling `sb.exec()` with the same sequence hits the cache — returning the stored result instantly, without writing any new `exec_start`/`exec_event`/`exec_complete` events (they're already in the ledger from the original run). Subsequent execs run live on the restored container and are logged normally.

```
Original run:
  exec #1: "pip install requests"  → logged, result cached
  checkpoint()                     → snapshot_id written to ledger
  exec #2: "python3 script.py"     → logged

Resume:
  exec #1: "pip install requests"  → replayed from cache (instant)
  exec #2: "python3 script.py"     → runs live on restored container
```

## The @alineo-labs/workflow lazy layer [#the-alineo-labsworkflow-lazy-layer]

`@alineo-labs/workflow` adds a declarative layer on top of the core API. You queue operations synchronously inside a builder callback; the queue is flushed when `.pipe()` or `.result()` is awaited.

```
workflow(client)
  .sandbox(opts, (sb) => {
    sb.exec("npm ci")          ← queued, not executed yet
    sb.checkpoint()            ← queued
    sb.retry(3, (sb) => {     ← queued
      sb.exec("npm test")
    })
  })
  .pipe(process.stdout)        ← flush: create sandbox, run ops, close sandbox
```

The workflow builder handles sandbox lifecycle automatically. Use it when you want control flow (`retry`, `when`, `forEach`) or multi-sandbox pipelines (`.parallel()`, `.sequence()`).

For straightforward tasks — run a command, capture output, read a file — the direct `SandboxHandle` API is simpler.


---

# Getting Started
URL: /docs/core/getting-started

Go from zero to a running workflow in a few minutes.



<Cards>
  <Card href="/docs/core/getting-started/what-is-alineo" title="What is the sandbox client?" description="The three-sentence pitch and a quick architecture overview." />

  <Card href="/docs/core/getting-started/installation" title="Installation" description="Install the package, pick a storage adapter, and connect the client." />

  <Card href="/docs/core/getting-started/quickstart" title="Quick start" description="Write and run your first workflow end-to-end." />

  <Card href="/docs/core/getting-started/how-it-works" title="How it works" description="Builder → engine → ledger → event stream — the full mental model." />
</Cards>


---

# Installation
URL: /docs/core/getting-started/installation

Install @alineo-labs/sandbox and a storage adapter, then wire up the client.



## Install packages [#install-packages]

```bash
bun add @alineo-labs/sandbox @alineo-labs/sqlite
```

For production, use the Postgres adapter instead:

```bash
bun add @alineo-labs/sandbox @alineo-labs/postgres
```

## Create the client [#create-the-client]

```ts
import { Sandbox } from "@alineo-labs/sandbox";
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const client = new Sandbox({
  baseUrl: process.env.OPEN_SANDBOX_URL ?? "http://localhost:8080",
  apiKey: process.env.OPEN_SANDBOX_API_KEY ?? "",
  adapter: new SQLiteAdapter("./ledger.db"),
});

// No connect() or close() needed — the adapter is lazily initialized
// and closed automatically when the process exits.
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  await sb.exec('echo "hello"').pipe(process.stdout);
} finally {
  await sb.close();
}
```

## Options [#options]

| Option           | Type              | Description                                                                                                                       |
| ---------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl`        | `string`          | OpenSandbox server URL                                                                                                            |
| `apiKey`         | `string`          | OpenSandbox API key (empty string for local dev)                                                                                  |
| `adapter`        | `IStorageAdapter` | Storage adapter for the run ledger                                                                                                |
| `maxConcurrency` | `number`          | Max simultaneous active sandboxes (default: unlimited)                                                                            |
| `useServerProxy` | `boolean`         | Route execd and proxy traffic through the server. Required when the server runs in Docker via `alineo init`. Defaults to `false`. |

## Local OpenSandbox [#local-opensandbox]

Run a local sandbox server with `uvx opensandbox-server`. Create `~/.sandbox.toml`:

```toml
[server]
host = "127.0.0.1"
port = 8080

[runtime]
type = "docker"
execd_image = "opensandbox/execd:v1.0.19"

[docker]
network_mode = "bridge"

[ingress]
mode = "direct"

[egress]
mode = "dns"
```

Then start the server:

```bash
uvx opensandbox-server
```


---

# Quick start
URL: /docs/core/getting-started/quickstart

Run your first sandbox command in under five minutes.



<Steps>
  <Step>
    ### Install [#install]

    ```bash
    bun add @alineo-labs/sandbox @alineo-labs/sqlite
    ```
  </Step>

  <Step>
    ### Start a local sandbox server [#start-a-local-sandbox-server]

    Run OpenSandbox locally with `uvx opensandbox-server`. See [Installation](/docs/core/getting-started/installation) for the full config.
  </Step>

  <Step>
    ### Hello world [#hello-world]

    ```ts title="hello.ts"
    import { Sandbox } from "@alineo-labs/sandbox";
    import { SQLiteAdapter } from "@alineo-labs/sqlite";

    const client = new Sandbox({
      baseUrl: "http://localhost:8080",
      adapter: new SQLiteAdapter("./ledger.db"),
    });

    const sb = await client.sandbox({
      image: "ubuntu:22.04",
      resources: { cpu: "500m", memory: "512Mi" },
      name: "hello-world",
    });

    try {
      await sb.exec('echo "hello world"').pipe(process.stdout);
    } finally {
      await sb.close();
    }
    ```

    ```bash
    bun hello.ts
    # hello world
    ```
  </Step>
</Steps>

## Capture output [#capture-output]

Await the `ExecHandle` directly to get `{ stdout, stderr, exitCode }`:

```ts
const sb = await client.sandbox({
  image: "node:20-slim",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  const { stdout: nodeVersion } = await sb.exec('node -e "process.stdout.write(process.version)"');
  await sb.exec(`echo "Running on Node ${nodeVersion.trim()}"`).pipe(process.stdout);
} finally {
  await sb.close();
}
```

## Write and read files [#write-and-read-files]

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  await sb.writeFile("/tmp/hello.txt", "hello from alineo\n");
  const content = await sb.readFile("/tmp/hello.txt");
  console.log(content.trim()); // "hello from alineo"
} finally {
  await sb.close();
}
```

## Orchestrate with the workflow builder [#orchestrate-with-the-workflow-builder]

For multi-step workflows with control flow, use `@alineo-labs/workflow`:

```bash
bun add @alineo-labs/workflow
```

```ts
import { workflow } from "@alineo-labs/workflow";

await workflow(client)
  .sandbox({ image: "ubuntu:22.04", resources: { cpu: "500m", memory: "512Mi" } }, (sb) => {
    sb.exec("echo 'step 1'");
    sb.retry(3, (sb) => sb.exec("npm test"), { backoff: "exponential" });
  })
  .pipe(process.stdout);
```

The workflow builder manages sandbox lifecycle automatically — no `try/finally` needed.

## Next steps [#next-steps]

* [How it works](/docs/core/getting-started/how-it-works) — the full mental model
* [exec & execCode](/docs/core/building/exec) — all exec options and streaming modes
* [File operations](/docs/core/building/file-ops) — writeFile, readFile, and more
* [Control flow](/docs/core/building/control-flow) — retry, when, forEach, parallel


---

# What is the sandbox client?
URL: /docs/core/getting-started/what-is-alineo

@alineo-labs/sandbox gives you live sandbox containers as first-class objects — spawn, exec, checkpoint, resume.



`@alineo-labs/sandbox` is a sandbox execution substrate built on top of [OpenSandbox](https://opensandbox.ai). You call `client.sandbox()` to get a live container, call methods on it, and close it when done. Every exec is durably logged to a ledger — if a run is interrupted, `client.resume()` restores from the last checkpoint and replays prior execs from cache.

```ts
import { Sandbox } from "@alineo-labs/sandbox";
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new SQLiteAdapter("./ledger.db"),
});

const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "512Mi" },
  name: "hello-world",
});

try {
  await sb.exec('echo "hello world"').pipe(process.stdout);
} finally {
  await sb.close();
}
```

## What you can do [#what-you-can-do]

* **Run commands** — `sb.exec("cmd")` returns an `ExecHandle`. Await it, pipe it, or iterate stdout chunk by chunk.
* **Run code** — `sb.execCode(code)` runs Python or JavaScript via the sandbox's interpreter.
* **Read and write files** — `writeFile`, `readFile`, `moveFile`, `deleteFile`, `searchFiles`, `listDirectory`.
* **Checkpoint and resume** — `sb.checkpoint()` snapshots the container. `client.resume(sandboxId)` restores it later.
* **Manage runs** — `client.sandboxes.list()`, `.get()`, `.delete()` — full ledger access.
* **Orchestrate** — `@alineo-labs/workflow` adds a lazy builder for `retry`, `when`, `forEach`, and parallel sandbox pipelines.

## Two usage modes [#two-usage-modes]

**Direct** — you hold the `SandboxHandle` object and call methods imperatively:

```ts
const sb = await client.sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } });
try {
  const { stdout } = await sb.exec("node --version");
  await sb.exec(`echo "using ${stdout.trim()}"`).pipe(process.stdout);
} finally {
  await sb.close();
}
```

**Workflow builder** — queue operations synchronously, flush on `.pipe()` or `.result()`:

```ts
import { workflow } from "@alineo-labs/workflow";

await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.exec("npm ci");
    sb.checkpoint();
    sb.retry(3, (sb) => sb.exec("npm test"), { backoff: "exponential" });
  })
  .pipe(process.stdout);
```

The workflow builder manages sandbox lifecycle automatically — no `try/finally` required.


---

# Error handling
URL: /docs/core/patterns/error-handling

Strict vs non-strict exec, CommandError, SandboxError, and ExecConnectionError.



## Strict mode (default) [#strict-mode-default]

By default, `sb.exec()` throws `CommandError` if the command exits with a non-zero code. This is "strict mode" — it mirrors how shell `set -e` works:

```ts
import { CommandError } from "@alineo-labs/sandbox";

const sb = await client.sandbox({
  image: "debian:bookworm-slim",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  await sb.exec("echo 'about to fail'").pipe(process.stdout);
  await sb.exec("exit 42"); // throws CommandError
  await sb.exec("echo 'this never runs'");
} catch (e) {
  if (e instanceof CommandError) {
    console.error(`CommandError: exit ${e.exitCode} — "${e.command}"`);
    // CommandError: exit 42 — "exit 42"
  }
} finally {
  await sb.close();
}
```

`CommandError` properties:

* `exitCode: number` — the process exit code
* `command: string` — the command string
* `sandboxId: string` — which sandbox it ran in

## Non-strict mode [#non-strict-mode]

Pass `{ strict: false }` to get the exit code in the result instead of throwing:

```ts
const { exitCode } = await sb.exec("test -f /etc/hosts", { strict: false });
if (exitCode === 0) {
  console.log("file exists");
} else {
  console.log("file missing");
}
```

This is useful for commands where a non-zero exit is a valid outcome (like `test`, `grep`, `diff`).

## Checking exit code and branching [#checking-exit-code-and-branching]

Combine non-strict exec with conditional logic:

```ts
const sb = await client.sandbox({
  image: "debian:bookworm-slim",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  const { exitCode } = await sb.exec("exit 1", { strict: false });
  if (exitCode === 0) {
    await sb.exec("echo success").pipe(process.stdout);
  } else {
    await sb.exec("echo 'command failed, handled gracefully'").pipe(process.stdout);
  }
} finally {
  await sb.close();
}
```

Or use `when` from `@alineo-labs/workflow` for the same pattern in a builder:

```ts
sb.exec("exit 1", { strict: false });
sb.when(
  (ctx) => ctx.exitCode === 0,
  (sb) => sb.exec("echo success"),
  (sb) => sb.exec("echo failure"),
);
```

## Other error types [#other-error-types]

### SandboxError [#sandboxerror]

Thrown when a sandbox fails to create, boot, or reach `Running` state:

```ts
import { SandboxError } from "@alineo-labs/sandbox";

try {
  const sb = await client.sandbox({
    image: "nonexistent:latest",
    resources: { cpu: "500m", memory: "256Mi" },
  });
} catch (e) {
  if (e instanceof SandboxError) {
    console.error(`SandboxError: ${e.message}`, e.sandboxId ?? "");
  }
}
```

`SandboxError.sandboxId` may be set if the container was assigned an ID before failing.

### ExecConnectionError [#execconnectionerror]

Thrown when execd inside the sandbox never becomes ready. The container is running from OpenSandbox's perspective, but the exec daemon isn't accepting connections after the retry window:

```ts
import { ExecConnectionError } from "@alineo-labs/sandbox";

try {
  await sb.exec("echo test");
} catch (e) {
  if (e instanceof ExecConnectionError) {
    console.error(`ExecConnectionError: ${e.message}`);
    // execd not ready for sandbox <id>
  }
}
```

## Error hierarchy [#error-hierarchy]

```
Error
└── WorkflowError
    ├── SandboxError       — container failed to start or reach Running
    ├── ExecConnectionError — execd not ready after retry window
    └── CommandError        — non-zero exit code (strict mode)
```

Import from `@alineo-labs/sandbox`:

```ts
import {
  CommandError,
  SandboxError,
  ExecConnectionError,
  WorkflowError,
} from "@alineo-labs/sandbox";
```

## Catching all alineo errors [#catching-all-alineo-errors]

Use `WorkflowError` as the base class to catch any alineo-specific error:

```ts
import { WorkflowError } from "@alineo-labs/sandbox";

try {
  await sb.exec("cmd");
} catch (e) {
  if (e instanceof WorkflowError) {
    console.error("alineo error:", e.message);
  } else {
    throw e; // re-throw unexpected errors
  }
}
```


---

# Flue integration
URL: /docs/core/patterns/flue

Use a @alineo-labs/sandbox SandboxHandle as a Flue session environment with @alineo-labs/flue.



`@alineo-labs/flue` adapts a `SandboxHandle` (from `@alineo-labs/sandbox`) to [Flue's](https://flueframework.com) `SandboxFactory` interface, so these sandboxes can back any Flue agent that needs a container to exec commands and manage files.

## Install [#install]

```bash
bun add @alineo-labs/flue @flue/runtime
```

## Usage [#usage]

Flue loads sandbox adapters from a file at `<source-dir>/sandboxes/<name>.ts`. Create `src/sandboxes/alineo.ts` in your Flue project:

```ts
// src/sandboxes/alineo.ts
import { alineo } from "@alineo-labs/flue";
import { Sandbox } from "@alineo-labs/sandbox";
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new SQLiteAdapter("./ledger.db"),
});

export default alineo(
  await client.sandbox({
    image: "node:22",
    resources: { cpu: "500m", memory: "512Mi" },
  }),
);
```

Then reference it in your agent definition:

```ts
import { defineAgent } from "@flue/runtime";
import alineoSandbox from "./sandboxes/alineo.ts";

export default defineAgent({
  sandbox: alineoSandbox,
  // ...
});
```

## API [#api]

```ts
function alineo(sandbox: SandboxHandle, opts?: { cwd?: string }): SandboxFactory;
```

| Parameter  | Type            | Description                                                                                                           |
| ---------- | --------------- | --------------------------------------------------------------------------------------------------------------------- |
| `sandbox`  | `SandboxHandle` | An already-created sandbox instance. Lifecycle is the caller's responsibility — the adapter never calls `sb.close()`. |
| `opts.cwd` | `string`        | Working directory passed to `createSandboxSessionEnv`. Defaults to `"/"`.                                             |

## SandboxApi coverage [#sandboxapi-coverage]

The adapter implements all nine `SandboxApi` methods:

| Method                        | Backed by                                                                   |
| ----------------------------- | --------------------------------------------------------------------------- |
| `exec(cmd, opts?)`            | `sb.exec()` with `strict: false`. Timeout via `Promise.race` + `Math.ceil`. |
| `readFile(path)`              | `sb.readFile()`                                                             |
| `readFileBuffer(path)`        | `sb.exec("base64 -w0 <path>")` → decoded `Uint8Array`                       |
| `writeFile(path, string)`     | `sb.writeFile()`                                                            |
| `writeFile(path, Uint8Array)` | Base64-encoded, piped through `base64 -d` in the container                  |
| `stat(path)`                  | `sb.exec("stat -c '%F\|%s\|%Y' <path>")` → parsed `FileStat`                |
| `readdir(path)`               | `sb.listDirectory(path, { depth: 1 })` → entry names                        |
| `exists(path)`                | `sb.exec("test -e <path>")` → exit code check                               |
| `mkdir(path, opts?)`          | `sb.exec("mkdir [-p] <path>")`                                              |
| `rm(path, opts?)`             | `sb.exec("rm [-r] [-f] <path>")`                                            |

## Known limits [#known-limits]

**Binary write cap** — `writeFile` with a `Uint8Array` base64-encodes the content and passes it through the shell. This relies on the Linux `ARG_MAX` limit (\~2 MB), so binary writes are capped at roughly **1.5 MB**. String writes (`writeFile(path, string)`) use the native file upload API and have no size restriction.

**No mid-flight cancellation** — `exec` honors `timeoutMs` via `Promise.race`, but `AbortSignal` is ignored. The underlying `SandboxHandle.exec()` does not expose signal propagation to the HTTP layer.

## Sandbox lifecycle [#sandbox-lifecycle]

The adapter never calls `sb.close()`. You are responsible for closing the sandbox when your agent session ends:

```ts
const sb = await client.sandbox({ image: "node:22", resources: { cpu: "500m", memory: "512Mi" } });
try {
  const factory = alineo(sb);
  // pass factory to Flue agent...
} finally {
  await sb.close();
}
```


---

# Forking sandboxes
URL: /docs/core/patterns/fork

Create independent sandbox copies from a live checkpoint with sb.fork().



`sb.fork()` snapshots the current sandbox and returns a new independent `SandboxHandle` from that state — without closing the original. Both containers keep running from the same filesystem point.

## Basic usage [#basic-usage]

```ts
const sb = await client.sandbox({
  image: "node:22",
  resources: { cpu: "1", memory: "512Mi" },
  name: "my-job",
});

try {
  await sb.exec("npm ci");

  const fork = await sb.fork("after-install");
  try {
    // Run different workloads in parallel on the same base state
    await Promise.all([
      sb.exec("npm test").pipe(process.stdout),
      fork.exec("npm run build").pipe(process.stdout),
    ]);
  } finally {
    await fork.close();
  }
} finally {
  await sb.close();
}
```

The optional string argument is a tag stored in the ledger (same as `sb.checkpoint(tag)`). Both the original and the fork remain live and independent after the call.

## How it works [#how-it-works]

`sb.fork(tag?)` does three things:

1. Calls `createSnapshot` on the current container and waits for the snapshot to be ready
2. Writes a `checkpoint_created` event to the ledger (same as `sb.checkpoint()`)
3. Spins up a new container from that snapshot and returns it as a `SandboxHandle`

The forked sandbox gets a new `sandboxId` and a ledger session named `fork-<parentName>-<shortId>`. The original sandbox is unaffected.

If the original sandbox has any credentials registered via `sb.credentials.set()`, the fork carries them over automatically — see [Credentials](/docs/core/concepts/credentials).

## Branching experiments [#branching-experiments]

Fork is well-suited for running diverging experiments from a shared base without rebuilding:

```ts
const env = client.environment("python-base", {
  image: "python:3.11-slim",
  resources: { cpu: "1", memory: "1Gi" },
  setup: async (sb) => {
    await sb.exec("pip install -q numpy");
  },
});

const sb = await env.sandbox();
try {
  // Fork into two independent experiment tracks
  const [forkA, forkB] = await Promise.all([sb.fork("track-a"), sb.fork("track-b")]);

  await Promise.all([
    forkA
      .exec("pip install -q pandas")
      .then(() =>
        forkA.exec("python3 -c 'import pandas; print(pandas.__version__)'").pipe(process.stdout),
      )
      .finally(() => forkA.close()),

    forkB
      .exec("pip install -q torch --index-url https://download.pytorch.org/whl/cpu")
      .then(() =>
        forkB.exec("python3 -c 'import torch; print(torch.__version__)'").pipe(process.stdout),
      )
      .finally(() => forkB.close()),
  ]);
} finally {
  await sb.close();
}
```

## Concurrency [#concurrency]

Each forked sandbox acquires a concurrency slot (counts against `SandboxClientOptions.maxConcurrency`). The original sandbox holds its slot as well. If you fork N times from one sandbox, you need N+1 available slots.

## Relationship to checkpoint and resume [#relationship-to-checkpoint-and-resume]

|                                       | `sb.checkpoint()` | `sb.fork()`                 |
| ------------------------------------- | ----------------- | --------------------------- |
| Snapshots the container               | Yes               | Yes                         |
| Writes `checkpoint_created` to ledger | Yes               | Yes                         |
| Original sandbox keeps running        | Yes               | Yes                         |
| Returns a new live sandbox            | No                | Yes                         |
| Use with `client.resume()`            | Yes               | Yes (snapshot is in ledger) |

Because `fork()` writes a `checkpoint_created` event, the resulting snapshot is visible in `sb.listCheckpoints()` and can also be used with `client.resume()`.

`sb.fork()` itself is really `sb.checkpoint()` followed by [`client.restoreSnapshot()`](/docs/core/api-reference/alineo-client#restoresnapshot), not `client.resume()` — the new sandbox starts with a clean exec history and does not replay any prior execs from the ledger, unlike `resume()`. The table above still applies since the same underlying snapshot is usable with either call afterwards; it's `fork()`'s own behavior that matches `restoreSnapshot()`, not `resume()`.


---

# Patterns
URL: /docs/core/patterns

Cross-cutting concerns — how to handle failure, time, and observability.



<Cards>
  <Card href="/docs/core/patterns/timeouts-and-cancellation" title="Timeouts & cancellation" description="Sandbox lifetime, bash timeout command, and try/finally cleanup." />

  <Card href="/docs/core/patterns/error-handling" title="Error handling" description="Strict vs non-strict exec, CommandError, SandboxError, and ExecConnectionError." />

  <Card href="/docs/core/patterns/run-management" title="Run management" description="List, resume, and delete runs from the ledger." />

  <Card href="/docs/core/patterns/observability" title="Observability" description="WorkflowHooks and OpenTelemetry tracing with @alineo-labs/otel." />

  <Card href="/docs/core/patterns/flue" title="Flue integration" description="Use @alineo-labs/sandbox sandboxes as Flue session environments with @alineo-labs/flue." />
</Cards>


---

# Named checkpoints
URL: /docs/core/patterns/named-checkpoints

Tag checkpoints with human-readable names, list them, and resume from any specific point.



By default, `client.resume()` restores from the most recent checkpoint. Named checkpoints let you tag specific points in a session and resume from any of them by name.

## Tagging a checkpoint [#tagging-a-checkpoint]

Pass a string to `sb.checkpoint()` to attach a tag:

```ts
await sb.exec("apt-get install -y python3-pip");
await sb.checkpoint("after-apt");

await sb.exec("pip install numpy pandas scikit-learn");
await sb.checkpoint("after-pip");

await sb.exec("python3 -c 'import sklearn; print(sklearn.__version__)'").pipe(process.stdout);
```

The tag is stored in the ledger alongside the snapshot ID. It has no effect on the snapshot itself.

## Listing checkpoints [#listing-checkpoints]

`sb.listCheckpoints()` returns all checkpoints for the sandbox in creation order:

```ts
const checkpoints = await sb.listCheckpoints();

for (const cp of checkpoints) {
  console.log(cp.tag, cp.snapshotId, new Date(cp.createdAt).toISOString());
}
// after-apt   snap_abc123  2024-01-15T10:00:00.000Z
// after-pip   snap_def456  2024-01-15T10:02:30.000Z
```

Each `CheckpointInfo` has:

| Field        | Type      | Description                                         |
| ------------ | --------- | --------------------------------------------------- |
| `snapshotId` | `string`  | OpenSandbox snapshot ID                             |
| `tag`        | `string?` | Tag passed to `sb.checkpoint()`, if any             |
| `createdAt`  | `number`  | Unix timestamp (ms) when the checkpoint was created |

## Resuming from a named checkpoint [#resuming-from-a-named-checkpoint]

Pass `{ tag }` to `client.resume()` to restore from a specific checkpoint instead of the latest:

```ts
const sandboxId = sb.sandboxId;

// resume from the earlier checkpoint, skipping the pip install state
const sbResume = await client.resume(sandboxId, { tag: "after-apt" });

try {
  // execs before "after-apt" are replayed from cache
  await sbResume.exec("apt-get install -y python3-pip");

  // runs live on the "after-apt" snapshot — pip packages are gone here
  await sbResume.exec("pip install torch").pipe(process.stdout);
} finally {
  await sbResume.close();
}
```

Without a tag, `client.resume()` falls back to the most recent checkpoint — existing behaviour is unchanged.

`client.resume()` throws `SandboxClientError` (status 404) if no checkpoint with the given tag is found in the session's ledger.

## Example: branching from a base state [#example-branching-from-a-base-state]

A common pattern is to take one base checkpoint and branch different experiments from it:

```ts
const sb = await client.sandbox({
  image: "python:3.11-slim",
  resources: { cpu: "1", memory: "1Gi" },
  name: "ml-experiment",
});

try {
  await sb.exec("pip install -q numpy");
  await sb.checkpoint("base");
  await sb.close();
} catch (err) {
  await sb.close();
  throw err;
}

const sandboxId = sb.sandboxId;

// Branch A: add pandas
const branchA = await client.resume(sandboxId, { tag: "base" });
try {
  await branchA.exec("pip install -q pandas");
  await branchA.exec("python3 -c 'import pandas; print(pandas.__version__)'").pipe(process.stdout);
} finally {
  await branchA.close();
}

// Branch B: add torch — restores from the same base snapshot
const branchB = await client.resume(sandboxId, { tag: "base" });
try {
  await branchB.exec("pip install -q torch --index-url https://download.pytorch.org/whl/cpu");
  await branchB.exec("python3 -c 'import torch; print(torch.__version__)'").pipe(process.stdout);
} finally {
  await branchB.close();
}
```

Both branches restore from the same `"base"` snapshot independently — no interference between them.


---

# Observability
URL: /docs/core/patterns/observability

SandboxHooks lifecycle callbacks and OpenTelemetry tracing with @alineo-labs/otel.



## SandboxHooks [#sandboxhooks]

`SandboxHooks` is a set of lifecycle callbacks you can pass to `client.sandbox()` to observe what's happening during a run. All hooks are optional.

```ts
import type { SandboxHooks } from "@alineo-labs/sandbox";

const hooks: SandboxHooks = {
  onSandboxCreated(sandboxId, name) {
    console.log(`[sandbox] created: ${name} (${sandboxId})`);
  },
  onExecStart(sandboxId, seq, cmd) {
    console.log(`[exec:${seq}] start: ${cmd.slice(0, 80)}`);
  },
  onExecComplete(sandboxId, seq, result) {
    console.log(`[exec:${seq}] done: exit ${result.exitCode}`);
  },
  onCheckpoint(sandboxId, snapshotId, name) {
    console.log(`[checkpoint] ${name ?? ""} → ${snapshotId}`);
  },
  onSandboxClosed(sandboxId) {
    console.log(`[sandbox] closed: ${sandboxId}`);
  },
  onSandboxFailed(sandboxId, error) {
    console.error(`[sandbox] failed: ${error.message}`);
  },
};

const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
  hooks,
});
```

## Hook signatures [#hook-signatures]

| Hook               | When                            | Arguments                        |
| ------------------ | ------------------------------- | -------------------------------- |
| `onSandboxCreated` | Container created and running   | `(sandboxId, name)`              |
| `onExecStart`      | `sb.exec()` called              | `(sandboxId, seq, cmd)`          |
| `onExecComplete`   | exec finished                   | `(sandboxId, seq, ExecResult)`   |
| `onCheckpoint`     | `sb.checkpoint()` completed     | `(sandboxId, snapshotId, name?)` |
| `onSandboxClosed`  | `sb.close()` completed          | `(sandboxId)`                    |
| `onSandboxFailed`  | sandbox creation or boot failed | `(sandboxId, error)`             |

## OpenTelemetry with @alineo-labs/otel [#opentelemetry-with-alineo-labsotel]

`@alineo-labs/otel` provides an `otelHooks()` factory that produces `SandboxHooks` emitting OTEL traces:

```bash
bun add @alineo-labs/otel @opentelemetry/api
```

```ts
import { otelHooks } from "@alineo-labs/otel";
import { trace } from "@opentelemetry/api";

const tracer = trace.getTracer("my-app");

const sb = await client.sandbox({
  image: "node:22",
  resources: { cpu: "500m", memory: "512Mi" },
  hooks: otelHooks(tracer),
});
```

### Span structure [#span-structure]

```
sandbox.run            ← root span (alineo.sandbox.id, alineo.sandbox.name)
  sandbox.exec         ← child per exec (alineo.exec.cmd, alineo.exec.seq, process.exit_code)
  sandbox.checkpoint   ← child per checkpoint (alineo.snapshot.id, alineo.checkpoint.name)
```

### OtelHooksOptions [#otelhooksoptions]

| Option           | Type      | Default | Description                                     |
| ---------------- | --------- | ------- | ----------------------------------------------- |
| `recordExitCode` | `boolean` | `true`  | Add `process.exit_code` attribute to exec spans |

```ts
const hooks = otelHooks(tracer, { recordExitCode: false });
```

### Setting up OTEL [#setting-up-otel]

`otelHooks` uses the `@opentelemetry/api` package — it integrates with whatever OTEL SDK you have configured in your application. Example with the Node SDK:

```ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { trace } from "@opentelemetry/api";
import { otelHooks } from "@alineo-labs/otel";

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({ url: "http://localhost:4318/v1/traces" }),
});
sdk.start();

const tracer = trace.getTracer("my-app");

const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
  hooks: otelHooks(tracer),
});
```


---

# Run management
URL: /docs/core/patterns/run-management

List, inspect, resume, and delete past sandbox sessions from the ledger.



Every sandbox session is stored in the ledger. `client.sandboxes` gives you full access to that history.

## List all sessions [#list-all-sessions]

```ts
const sessions = await client.sandboxes.list();

for (const session of sessions) {
  console.log(session.sandboxId, session.name, session.status, session.execCount);
}
```

Sessions are returned newest first.

## SandboxDetails [#sandboxdetails]

Each session has:

| Field         | Type            | Description                                                                                        |
| ------------- | --------------- | -------------------------------------------------------------------------------------------------- |
| `sandboxId`   | `string`        | OpenSandbox container ID                                                                           |
| `name`        | `string`        | User-provided name (or auto-generated)                                                             |
| `status`      | `SandboxStatus` | `"running"` or `"completed"` — that's the full set; there is no `"failed"` or `"cancelled"` status |
| `startedAt`   | `number`        | Unix timestamp (ms) of `sandbox_created` event                                                     |
| `completedAt` | `number?`       | Unix timestamp (ms) of `sandbox_closed` event                                                      |
| `execCount`   | `number`        | Number of completed execs                                                                          |

There is no `error` field on `SandboxDetails` — a sandbox that failed to start doesn't get a ledger entry with a `"failed"` status; it just never has a `sandbox_created` event to derive one from.

## Filter by name [#filter-by-name]

```ts
// All sessions with a given name
const sessions = await client.sandboxes.listByName("my-ci-job");
```

## Filter by status or date [#filter-by-status-or-date]

```ts
import { SandboxStatus } from "@alineo-labs/sandbox";

// Only running sessions
const running = await client.sandboxes.list({ status: SandboxStatus.Running });

// Sessions started before a timestamp
const old = await client.sandboxes.list({ before: Date.now() - 86_400_000 });

// Limit results
const recent = await client.sandboxes.list({ limit: 10 });
```

## Get a single session [#get-a-single-session]

```ts
const session = await client.sandboxes.get("my-job", sandboxId);
if (!session) {
  console.log("not found");
}
```

## Delete a session [#delete-a-session]

Removes all ledger events for the session. Does not affect the container (which is already closed):

```ts
await client.sandboxes.delete("my-job", sandboxId);
```

## Resume from a checkpoint [#resume-from-a-checkpoint]

If a session was checkpointed before it was closed, you can restore it:

```ts
const sbResume = await client.resume(sandboxId);
try {
  // Execs before the checkpoint are replayed from cache
  await sbResume.exec("pip install -q requests && echo installed");
  // Execs after the checkpoint run live on the restored container
  await sbResume.exec("python3 script.py").pipe(process.stdout);
} finally {
  await sbResume.close();
}
```

`client.resume()` throws `SandboxClientError` (status 404) if no session with that ID is found in the ledger, and if no checkpoint exists in the session's history.

## Typical cleanup pattern [#typical-cleanup-pattern]

For scripts that accumulate many sessions, periodically clean up completed runs:

```ts
import { SandboxStatus } from "@alineo-labs/sandbox";

const completed = await client.sandboxes.list({ status: SandboxStatus.Completed });

for (const session of completed) {
  const ageMs = Date.now() - session.startedAt;
  if (ageMs > 7 * 24 * 60 * 60 * 1000) {
    // older than 7 days
    await client.sandboxes.delete(session.name, session.sandboxId);
  }
}
```


---

# Timeouts & cancellation
URL: /docs/core/patterns/timeouts-and-cancellation

Sandbox lifetime limits, bash-level command timeouts, and cleanup with try/finally.



## Sandbox lifetime [#sandbox-lifetime]

Set a maximum lifetime for the entire sandbox container via `SandboxOptions.timeout` (in seconds). The OpenSandbox server terminates the container after this duration:

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
  timeout: 300, // terminate after 5 minutes
});
```

This is a hard limit on the container — not on individual commands.

## Per-command timeout via bash [#per-command-timeout-via-bash]

Use the bash `timeout` command to limit how long a single command can run. The `timeout` utility exits with code `124` if the limit is reached:

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  // Times out after 1 second, exits with 124
  const { exitCode } = await sb.exec("timeout 1 sleep 30 || echo 'timed out'", { strict: false });
  console.log(`exit code: ${exitCode}`); // 0 (the || echo masked it)
} finally {
  await sb.close();
}
```

To get the actual timeout exit code:

```ts
const { exitCode } = await sb.exec("timeout 10 long-running-command", { strict: false });
if (exitCode === 124) {
  console.log("command timed out");
} else if (exitCode !== 0) {
  console.log(`command failed with ${exitCode}`);
}
```

## Cleanup with try/finally [#cleanup-with-tryfinally]

Always wrap sandbox usage in `try/finally` to ensure `sb.close()` runs even if an exec throws or a timeout fires:

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "512Mi" },
});

try {
  await sb.exec("step 1").pipe(process.stdout);
  await sb.exec("step 2").pipe(process.stdout);
  // If step 2 throws CommandError, finally still runs
} finally {
  await sb.close(); // always deletes the container
}
```

`close()` is idempotent — calling it multiple times is safe.

## Handling long-running commands [#handling-long-running-commands]

For commands that might run indefinitely, combine `timeout` with error handling:

```ts
const sb = await client.sandbox({
  image: "ubuntu:22.04",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  const result = await sb.exec("timeout 60 ./run-tests.sh", { strict: false });
  if (result.exitCode === 124) {
    console.error("Tests timed out after 60 seconds");
  } else if (result.exitCode !== 0) {
    console.error(`Tests failed: exit ${result.exitCode}`);
    console.error(result.stderr);
  }
} finally {
  await sb.close();
}
```

## Retry with backoff [#retry-with-backoff]

For flaky operations, use `retry` from `@alineo-labs/workflow` instead of manual retry loops. It handles backoff and re-runs cleanly:

```ts
import { workflow } from "@alineo-labs/workflow";

await workflow(client)
  .sandbox({ image: "ubuntu:22.04", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.retry(5, (sb) => sb.exec("curl --retry 0 --max-time 10 https://example.com"), {
      delayMs: 1000,
      backoff: "exponential",
    });
  })
  .pipe(process.stdout);
```

See [Control flow](/docs/core/building/control-flow) for full retry docs.


---

# Workflow Builder
URL: /docs/workflow

@alineo-labs/workflow — a lazy declarative layer on top of the Core SDK for orchestrating multi-sandbox pipelines.



<Cards>
  <Card href="/docs/workflow/getting-started" title="Getting Started" description="What the workflow builder is and when to use it over the Core SDK." />

  <Card href="/docs/workflow/building" title="Building" description="SandboxBuilder ops, control flow, parallel execution, and capturing values." />

  <Card href="/docs/workflow/api-reference" title="API Reference" description="Complete reference for workflow(), WorkflowBuilder, and SandboxBuilder." />
</Cards>


---

# Builder API
URL: /docs/workflow/api-reference/builder

workflow(), WorkflowBuilder, and SandboxBuilder — the @alineo-labs/workflow lazy orchestration layer.



```ts
import { workflow, WorkflowBuilder, SandboxBuilder } from "@alineo-labs/workflow";
```

## workflow() [#workflow]

```ts
workflow(client: Sandbox): WorkflowBuilder
```

Creates a `WorkflowBuilder` attached to a `Sandbox` client.

```ts
import { workflow } from "@alineo-labs/workflow";

await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "512Mi" } }, (sb) => {
    sb.exec("npm ci");
    sb.exec("npm test");
  })
  .pipe(process.stdout);
```

## WorkflowBuilder [#workflowbuilder]

Collects stages and executes them all when `.pipe()` or `.result()` is awaited. Sandbox lifecycle (create + close) is managed automatically.

### .sandbox() [#sandbox]

```ts
.sandbox(opts: SandboxOptions, fn: (sb: SandboxBuilder) => void): this
```

Add a sandbox stage. The `fn` callback receives a `SandboxBuilder` to queue operations synchronously. Multiple `.sandbox()` calls run sequentially.

### .parallel() [#parallel]

```ts
.parallel(configs: SandboxOptions[], fn: (sb: SandboxBuilder) => void): this
```

Run the same operation across multiple containers simultaneously. All configs receive the same `fn`. Results are merged.

```ts
await workflow(client)
  .parallel([{ image: "node:20" }, { image: "node:22" }, { image: "node:24" }], (sb) =>
    sb.exec("npm test"),
  )
  .pipe(process.stdout);
```

### .sequence() [#sequence]

```ts
.sequence(steps: SequenceStep[]): this
```

Run sandboxes one after another. Each step's callback receives the previous step's `WorkflowResult` as `prev`:

```ts
await workflow(client)
  .sequence([
    {
      image: "node:22",
      name: "build",
      resources: { cpu: "1", memory: "512Mi" },
      run: (sb) => sb.exec("npm run build"),
    },
    {
      image: "ubuntu:22.04",
      name: "deploy",
      resources: { cpu: "500m", memory: "256Mi" },
      run: (sb, prev) => sb.exec("./deploy.sh"),
    },
  ])
  .pipe(process.stdout);
```

### .pipe() [#pipe]

```ts
await workflow.pipe(writable: { write(chunk: string): unknown }): Promise<void>
```

Execute the workflow and stream stdout to a writable. Flushes all queued stages.

### .result() [#result]

```ts
await workflow.result(): Promise<WorkflowResult>
```

Execute the workflow and return the combined result:

```ts
interface WorkflowResult {
  stdout: string; // concatenated stdout from all sandboxes
  vars: Record<string, unknown>; // values captured by readFile(path, as)
}
```

## SandboxBuilder [#sandboxbuilder]

Queues operations synchronously. All methods return `this` for chaining. Nothing runs until the `WorkflowBuilder` is flushed.

### .exec() [#exec]

```ts
sb.exec(cmd: string, opts?: ExecOptions): this
```

Queue a shell command.

### .execCode() [#execcode]

```ts
sb.execCode(code: string, opts?: ExecCodeOptions): this
```

Queue a code execution via the interpreter.

### .writeFile() [#writefile]

```ts
sb.writeFile(path: string, content: string): this
```

Queue a file write into the sandbox.

### .readFile() [#readfile]

```ts
sb.readFile(path: string, as: string): this
```

Queue a file read. The content is stored in `vars[as]` and available via `WorkflowResult.vars`.

### .moveFile() [#movefile]

```ts
sb.moveFile(from: string, to: string): this
```

Queue a file move inside the sandbox.

### .deleteFile() [#deletefile]

```ts
sb.deleteFile(path: string): this
```

Queue a file deletion.

### .checkpoint() [#checkpoint]

```ts
sb.checkpoint(name?: string): this
```

Queue a container snapshot.

### .retry() [#retry]

```ts
sb.retry(
  maxAttempts: number,
  fn: (sb: SandboxBuilder) => void,
  opts?: RetryOptions,
): this
```

Queue a retry block. Runs up to `maxAttempts` total attempts (the first run plus retries), waiting `delayMs` between each on failure.

```ts
interface RetryOptions {
  delayMs?: number; // default: 1000
  backoff?: "fixed" | "exponential"; // default: "fixed"
}
```

### .when() [#when]

```ts
sb.when(
  pred: (ctx: { stdout: string; exitCode: number; vars: Record<string, unknown> }) => boolean,
  then: (sb: SandboxBuilder) => void,
  otherwise?: (sb: SandboxBuilder) => void,
): this
```

Queue a conditional branch. `pred` is evaluated at flush time with the current execution context.

### .forEach() [#foreach]

```ts
sb.forEach(
  items: unknown[],
  fn: (sb: SandboxBuilder, item: unknown, index: number) => void,
  opts?: ForEachOptions,
): this
```

Queue iteration over a list of items.

```ts
interface ForEachOptions {
  concurrency?: number; // default: 1 (sequential)
}
```


---

# API Reference
URL: /docs/workflow/api-reference

Complete reference for the @alineo-labs/workflow lazy orchestration layer.



<Cards>
  <Card href="/docs/workflow/api-reference/builder" title="Builder API" description="workflow(), WorkflowBuilder, and SandboxBuilder from @alineo-labs/workflow." />
</Cards>


---

# Getting Started
URL: /docs/workflow/getting-started

What the Workflow Builder is, and how to run your first workflow.



<Cards>
  <Card href="/docs/workflow/getting-started/what-is-workflow" title="What is the Workflow Builder?" description="@alineo-labs/workflow adds a lazy declarative layer over the Core SDK — one await at the end, lifecycle managed automatically." />

  <Card href="/docs/workflow/getting-started/quickstart" title="Quickstart" description="Run your first workflow — install, configure, and run a multi-step pipeline in one await." />
</Cards>


---

# Quickstart
URL: /docs/workflow/getting-started/quickstart

Run your first workflow — install, configure, and run a multi-step pipeline in one await.



## Install [#install]

```bash
npm install @alineo-labs/sandbox @alineo-labs/workflow
```

## Configure [#configure]

```ts
import { Sandbox } from "@alineo-labs/sandbox";
import { SQLiteAdapter } from "@alineo-labs/sqlite";
import { workflow } from "@alineo-labs/workflow";

const client = new Sandbox({
  baseUrl: "http://localhost:8080",
  adapter: new SQLiteAdapter("./ledger.db"),
});
```

No `connect()` call needed — the adapter initializes lazily on first use.

## Run a workflow [#run-a-workflow]

```ts
await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "512Mi" } }, (sb) => {
    sb.exec("node --version");
    sb.exec("npm --version");
  })
  .pipe(process.stdout);
```

No `close()` call needed either — `Sandbox` has no such method; the adapter closes itself automatically when the process exits.

The callback receives a `SandboxBuilder`. All calls inside it queue operations synchronously — nothing runs until `.pipe()` or `.result()` is awaited. The sandbox is created, all ops are flushed in order, then the sandbox is closed automatically.

## Capture output [#capture-output]

Use `.result()` instead of `.pipe()` to get the combined stdout and any captured file values:

```ts
const { stdout, vars } = await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "512Mi" } }, (sb) => {
    sb.exec("node --version > /tmp/ver.txt");
    sb.readFile("/tmp/ver.txt", "nodeVersion");
  })
  .result();

console.log(vars.nodeVersion); // "v22.x.x\n"
```

## Next steps [#next-steps]

* [SandboxBuilder operations](/docs/workflow/building/sandbox-builder) — the full set of queued ops
* [Control flow](/docs/workflow/building/control-flow) — retry, when, forEach
* [Parallel & sequence](/docs/workflow/building/parallel-sequence) — multi-sandbox patterns


---

# What is the Workflow Builder?
URL: /docs/workflow/getting-started/what-is-workflow

@alineo-labs/workflow adds a lazy declarative layer over the Core SDK — one await at the end, lifecycle managed automatically.



`@alineo-labs/workflow` is a separate package that sits on top of the `alineo` Core SDK. It gives you a builder API where you describe what should happen synchronously, then flush everything with a single `await` at the end.

```ts
import { workflow } from "@alineo-labs/workflow";

await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "512Mi" } }, (sb) => {
    sb.exec("npm ci");
    sb.exec("npm test");
  })
  .pipe(process.stdout);
```

## Core SDK vs Workflow Builder [#core-sdk-vs-workflow-builder]

|               | Core SDK (`alineo`)                     | Workflow Builder (`@alineo-labs/workflow`) |
| ------------- | --------------------------------------- | ------------------------------------------ |
| Style         | Imperative — hold objects, call methods | Declarative — describe ops, flush once     |
| Await         | Per-operation                           | Once at the end                            |
| Lifecycle     | Manual `try/finally sb.close()`         | Managed automatically                      |
| Multi-sandbox | Multiple variables                      | `.parallel()`, `.sequence()`               |
| Streaming     | `ExecHandle.pipe()`                     | `.pipe(writable)` on the workflow          |
| Values        | `await sb.exec(...)`                    | `readFile(path, as)` → `vars`              |

## When to use the Workflow Builder [#when-to-use-the-workflow-builder]

Use `@alineo-labs/workflow` when:

* You want the simplest possible multi-sandbox orchestration
* You need `.parallel()` across N containers without managing each one manually
* You want to sequence sandboxes where each step depends on the previous result
* You want a single `await` regardless of how many sandboxes and operations are involved

Use the Core SDK directly when:

* You need fine-grained control over individual exec results mid-flow
* You're integrating sandbox execution into existing imperative async code
* You want to stream output interleaved with other async work

## Installation [#installation]

```bash
npm install @alineo-labs/workflow
# or
bun add @alineo-labs/workflow
```

The workflow builder requires `alineo` as a peer dependency — you'll already have it if you're using the Core SDK.


---

# Capturing Values
URL: /docs/workflow/building/capturing-values

Use readFile(path, as) to pull file contents out of the sandbox and into WorkflowResult.vars.



`SandboxBuilder` doesn't return `ExecHandle` — ops are queued, not executed. To get a value out of the sandbox, write it to a file inside the container and read it back with `readFile`.

## readFile [#readfile]

```ts
sb.exec("node --version > /tmp/ver.txt");
sb.readFile("/tmp/ver.txt", "nodeVersion");
```

After the workflow resolves, `vars.nodeVersion` contains the file's content as a string.

```ts
const { vars } = await workflow(client)
  .sandbox(opts, (sb) => {
    sb.exec("node --version > /tmp/ver.txt");
    sb.readFile("/tmp/ver.txt", "nodeVersion");
  })
  .result();

console.log(vars.nodeVersion); // "v22.x.x\n"
```

## Multiple values [#multiple-values]

Capture as many values as you need — each gets its own key:

```ts
const { vars } = await workflow(client)
  .sandbox(opts, (sb) => {
    sb.exec("node --version > /tmp/node.txt");
    sb.exec("npm --version > /tmp/npm.txt");
    sb.exec("uname -r > /tmp/kernel.txt");
    sb.readFile("/tmp/node.txt", "node");
    sb.readFile("/tmp/npm.txt", "npm");
    sb.readFile("/tmp/kernel.txt", "kernel");
  })
  .result();

console.log(vars); // { node: "v22.x.x\n", npm: "10.x.x\n", kernel: "..." }
```

## Passing values between sequence steps [#passing-values-between-sequence-steps]

`readFile` values are available in the next step via `prev.vars`:

```ts
await workflow(client)
  .sequence([
    {
      image: "node:22",
      resources: opts.resources,
      run: (sb) => {
        sb.exec("npm run build:version > /tmp/version.txt");
        sb.readFile("/tmp/version.txt", "version");
      },
    },
    {
      image: "ubuntu:22.04",
      resources: opts.resources,
      run: (sb, prev) => {
        const version = String(prev.vars.version).trim();
        sb.exec(`./deploy.sh --version ${version}`);
      },
    },
  ])
  .pipe(process.stdout);
```

## stdout [#stdout]

The combined stdout from all exec calls is also available in `WorkflowResult.stdout`. For selective capture, `readFile` is more reliable than parsing stdout.

```ts
const { stdout } = await workflow(client)
  .sandbox(opts, (sb) => {
    sb.exec("npm test");
  })
  .result();
```


---

# Control Flow
URL: /docs/workflow/building/control-flow

retry, when, and forEach — conditional branching and iteration inside a SandboxBuilder.



Control flow primitives let you express branching and looping inside the `SandboxBuilder` callback. They queue lazily like every other operation, and the predicates/callbacks run at flush time with the current execution context.

## retry [#retry]

Retry a block of operations up to `maxAttempts` times on failure.

```ts
sb.retry(
  3,
  (sb) => {
    sb.exec("curl -f https://api.example.com/health");
  },
  { delayMs: 2000, backoff: "exponential" },
);
```

### RetryOptions [#retryoptions]

| Option    | Type                       | Default   | Description                |
| --------- | -------------------------- | --------- | -------------------------- |
| `delayMs` | `number`                   | `1000`    | Wait between attempts (ms) |
| `backoff` | `"fixed" \| "exponential"` | `"fixed"` | Delay growth strategy      |

With `backoff: "exponential"`, the delay doubles each attempt (`delayMs * 2^(attempt-1)`): 1s, 2s, 4s, ...

## when [#when]

Branch on runtime state. The predicate receives the current execution context.

```ts
sb.exec("test -f /tmp/cache.tar.gz", { strict: false });

sb.when(
  (ctx) => ctx.exitCode === 0,
  (sb) => sb.exec("tar -xzf /tmp/cache.tar.gz"),
  (sb) => sb.exec("npm ci"),
);
```

### Predicate context [#predicate-context]

```ts
// shape of the predicate's ctx argument (a subset of the internal FlushContext)
interface WhenPredicateContext {
  stdout: string; // accumulated stdout so far
  exitCode: number; // exit code of the last exec
  vars: Record<string, unknown>; // values captured by readFile(path, as)
}
```

The `otherwise` branch is optional. If the predicate is false and no `otherwise` is provided, the when block is skipped.

## forEach [#foreach]

Iterate over a list of items, queueing the same operations for each.

```ts
const packages = ["lodash", "zod", "tsx"];

sb.forEach(packages, (sb, pkg) => {
  sb.exec(`npm install ${pkg}`);
});
```

Run iterations in parallel with `concurrency`:

```ts
sb.forEach(packages, (sb, pkg) => sb.exec(`npm install ${pkg}`), { concurrency: 3 });
```

### ForEachOptions [#foreachoptions]

| Option        | Type     | Default | Description             |
| ------------- | -------- | ------- | ----------------------- |
| `concurrency` | `number` | `1`     | Max parallel iterations |

With `concurrency > 1`, each parallel branch flushes against a shallow copy of the outer `FlushContext`. `stdout`/`exitCode` writes inside those branches don't propagate back to the outer context afterward — only `vars` (an object reference) does. Don't rely on `ctx.stdout`/`ctx.exitCode` reflecting what happened inside a concurrent `forEach` after it completes.

## Composing [#composing]

Control flow primitives nest naturally:

```ts
sb.retry(3, (sb) => {
  sb.exec("./deploy.sh", { strict: false });

  sb.when(
    (ctx) => ctx.exitCode !== 0,
    (sb) => {
      sb.exec("./rollback.sh");
      sb.forEach(["web", "api", "worker"], (sb, svc) => {
        sb.exec(`systemctl restart ${svc}`);
      });
    },
  );
});
```


---

# Building
URL: /docs/workflow/building

SandboxBuilder operations, control flow, parallel/sequence patterns, and capturing values.



<Cards>
  <Card href="/docs/workflow/building/sandbox-builder" title="SandboxBuilder" description="The object passed to every workflow callback — queues operations synchronously, flushes them when the workflow is awaited." />

  <Card href="/docs/workflow/building/control-flow" title="Control Flow" description="retry, when, and forEach — conditional branching and iteration inside a SandboxBuilder." />

  <Card href="/docs/workflow/building/parallel-sequence" title="Parallel & Sequence" description="Run the same workflow across multiple containers simultaneously, or chain sandboxes where each step sees the previous result." />

  <Card href="/docs/workflow/building/capturing-values" title="Capturing Values" description="Use readFile(path, as) to pull file contents out of the sandbox and into WorkflowResult.vars." />
</Cards>


---

# Parallel & Sequence
URL: /docs/workflow/building/parallel-sequence

Run the same workflow across multiple containers simultaneously, or chain sandboxes where each step sees the previous result.



## parallel [#parallel]

Run the same set of operations across multiple containers at the same time.

```ts
await workflow(client)
  .parallel(
    [
      { image: "node:20", resources: { cpu: "500m", memory: "512Mi" } },
      { image: "node:22", resources: { cpu: "500m", memory: "512Mi" } },
      { image: "node:24", resources: { cpu: "500m", memory: "512Mi" } },
    ],
    (sb) => sb.exec("npm test"),
  )
  .pipe(process.stdout);
```

All configs receive the same `fn` callback. The sandboxes run concurrently — stdout from all of them is merged in the result. Each sandbox is its own isolated container.

### Cross-version matrix testing [#cross-version-matrix-testing]

```ts
const nodeVersions = ["18", "20", "22"];

await workflow(client)
  .parallel(
    nodeVersions.map((v) => ({
      image: `node:${v}`,
      resources: { cpu: "500m", memory: "512Mi" },
    })),
    (sb) => {
      sb.exec("npm ci");
      sb.exec("npm test");
    },
  )
  .pipe(process.stdout);
```

## sequence [#sequence]

Chain sandboxes one after another. Each step's callback receives the previous step's `WorkflowResult` as `prev`.

```ts
await workflow(client)
  .sequence([
    {
      image: "node:22",
      resources: { cpu: "1", memory: "1Gi" },
      name: "build",
      run: (sb) => {
        sb.exec("npm ci");
        sb.exec("npm run build");
        sb.exec("tar -czf /tmp/dist.tar.gz dist/");
        sb.readFile("/tmp/dist.tar.gz", "artifact");
      },
    },
    {
      image: "ubuntu:22.04",
      resources: { cpu: "500m", memory: "512Mi" },
      name: "deploy",
      run: (sb, prev) => {
        // prev.vars.artifact is the tarball from the build step
        sb.exec("./deploy.sh");
      },
    },
  ])
  .pipe(process.stdout);
```

### SequenceStep [#sequencestep]

```ts
interface SequenceStep {
  image: string | { uri: string; auth?: { username: string; password: string } };
  resources: { cpu: string; memory: string; gpu?: string };
  env?: Record<string, string>;
  timeout?: number;
  name?: string;
  run: (sb: SandboxBuilder, prev?: WorkflowResult) => void;
}
```

`prev` is `undefined` for the first step.

## Combining parallel and sequence [#combining-parallel-and-sequence]

`.parallel()` and `.sequence()` are both methods on `WorkflowBuilder` and can be chained:

```ts
await workflow(client)
  .sandbox(opts, (sb) => sb.exec("npm ci")) // setup
  .parallel(matrix, (sb) => sb.exec("npm test")) // test across versions
  .pipe(process.stdout);
```

Each call adds a stage. Stages run in the order they are added.


---

# SandboxBuilder
URL: /docs/workflow/building/sandbox-builder

The object passed to every workflow callback — queues operations synchronously, flushes them when the workflow is awaited.



`SandboxBuilder` is the object your callback receives inside `.sandbox()`, `.parallel()`, and `.sequence()`. Every method queues an operation. Nothing runs until the `WorkflowBuilder` is flushed via `.pipe()` or `.result()`.

All methods return `this` for chaining.

## exec [#exec]

```ts
sb.exec("npm ci");
sb.exec("npm test", { strict: false });
```

Queue a shell command. `strict: true` (default) throws `CommandError` on non-zero exit. `strict: false` captures the exit code instead.

## execCode [#execcode]

```ts
import { CodeLanguage } from "@alineo-labs/opensandbox";

sb.execCode(`print("hello")`, { context: { id: "my-context", language: CodeLanguage.Python } });
```

Queue a code snippet via the interpreter. There's no `language` shorthand or `stateful` flag — `ExecCodeOptions` only takes an optional `context: { id, language }`. Reusing the same `id` across calls shares interpreter state between them; a call with no `context` runs stateless.

`SandboxBuilder` has no queued equivalent of `SandboxHandle.createCodeContext()` — you supply the context object directly (as above) rather than obtaining one from execd first. If you need a context created via `createCodeContext()`, use the direct `SandboxHandle` API outside the workflow builder instead (see [Executing code](/docs/core/building/exec)).

## writeFile [#writefile]

```ts
sb.writeFile("/app/config.json", JSON.stringify({ port: 3000 }));
```

Queue a file write into the sandbox filesystem.

## readFile [#readfile]

```ts
sb.readFile("/tmp/output.txt", "result");
```

Queue a file read. The content lands in `WorkflowResult.vars["result"]` after the workflow completes.

## moveFile [#movefile]

```ts
sb.moveFile("/tmp/build.tar.gz", "/artifacts/build.tar.gz");
```

Queue a file move within the sandbox.

## deleteFile [#deletefile]

```ts
sb.deleteFile("/tmp/scratch.txt");
```

Queue a file deletion.

## checkpoint [#checkpoint]

```ts
sb.checkpoint("after-install");
```

Queue a container snapshot. The checkpoint name is optional. After checkpointing, `client.resume(sandboxId)` can restore the container to this state.

See [Snapshots](/docs/core/building/snapshots) for the full checkpoint/resume model.


---

# Alineo SDK
URL: /docs/agent

Run AI coding agents (Pi) in isolated sandbox containers with a simple TypeScript API.



<Cards>
  <Card href="/docs/agent/getting-started" title="Getting Started" description="Load an agent spec, send prompts, and stream responses in minutes." />

  <Card href="/docs/agent/api-reference" title="API Reference" description="Complete reference for Alineo, AgentSpec, AgentStream, and all Pi RPC commands." />
</Cards>


---

# Alineo
URL: /docs/agent/api-reference/agent

Complete reference for the Alineo class, AgentSpec, AgentStream, and all Pi RPC commands.



```ts
import {
  Alineo,
  textOnly,
  type AgentSpec,
  type AgentStream,
  type AgentEvent,
  type SetupStep,
} from "alineo";
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const adapter = new SQLiteAdapter("./.alineo/ledger.db");
```

`alineo` has no storage-adapter dependency of its own — every method below that takes `opts.adapter` requires it. Use `SQLiteAdapter` from `@alineo-labs/sqlite` for local dev or `PostgresAdapter` from `@alineo-labs/postgres` for production.

***

## Alineo [#alineo]

A live AI coding agent running inside an OpenSandbox container. Wraps a Pi CLI process (`pi --mode rpc --approve`) in an HTTP bridge so the host can send prompts and receive streamed responses.

### Alineo.load() [#alineoload]

```ts
static async load(
  spec: AgentSpec | Record<string, unknown>,
  opts: {
    adapter: IStorageAdapter;
    rebuild?: boolean;
    spawnDepth?: number;
    maxAgents?: number;
    onEgressRequest?: EgressRequestHandler;
  },
): Promise<Alineo>
```

Validate `spec`, spin up a `node:22` sandbox, install the Pi CLI and any `setup` steps, and return a ready `Alineo`. `spec` is an already-parsed object, not a file path — read one from disk yourself first (`await Bun.file(path).json()`), fetch it over HTTP, or build it programmatically. It's validated internally regardless (via `validateAgentSpec()`), so a raw `JSON.parse()`'d object works fine.

On first load the container is checkpointed after install. Subsequent calls restore from the snapshot — skipping the install and starting in \~3s instead of \~90s. See [Snapshotting](/docs/agent/getting-started/snapshotting).

```ts
const spec = await Bun.file("./agents/my-agent.json").json();
const agent = await Alineo.load(spec, { adapter });

// Force a full reinstall, ignoring the cached snapshot:
const agent2 = await Alineo.load(spec, { adapter, rebuild: true });
```

`spawnDepth`/`maxAgents` override the spec's own fields — see [Spawning child agents](#spawning-child-agents) below.

`onEgressRequest` is **required** when the spec has any `env` credential binding with `approval: "hold"` — it decides each first outbound request to a held host. See [Permission gate — holding network egress](/docs/agent/getting-started/permissions#holding-network-egress-for-approval).

### Alineo.resume() [#alineoresume]

```ts
static async resume(
  sandboxId: string,
  opts: {
    adapter: IStorageAdapter;
    spec?: AgentSpec | Record<string, unknown>;
    specPath?: string;
  },
): Promise<Alineo>
```

Reconnect to an existing sandbox after the host process has exited. The container must still be running. Only the bridge process is restarted — Pi and the workspace are untouched. Pi resumes the most recent session via `--continue`.

Unlike `load()`, `resume()` still accepts a bare path (`specPath`) — or, if you already have the spec object in memory, pass it directly via `spec` and skip the read. If neither is set, the ledger is queried for the sandbox's name and the spec is read from `./agents/<name>.json`.

```ts
// Save the sandbox ID from a previous run...
const agent = await Alineo.resume(savedSandboxId, { adapter });

// Or pass the spec path explicitly:
const agent2 = await Alineo.resume(savedSandboxId, { adapter, specPath: "./agents/my-agent.json" });

// Or pass an already-parsed spec object:
const agent3 = await Alineo.resume(savedSandboxId, { adapter, spec });
```

### Alineo.attach() [#alineoattach]

```ts
static async attach(
  sandboxId: string,
  opts: { adapter: IStorageAdapter; name: string; resources?: { cpu: string; memory: string; gpu?: string } },
): Promise<Alineo>
```

Connect to an already-running sandbox **without** touching its Pi bridge — unlike `resume()`, which kills and restarts the bridge process. Use this when you only need `.spawn()`/`.sandbox`, not `.prompt()`/`.bash()` (the returned `Alineo` has no bridge, so those throw).

The main caller is `alineo fork`: it runs as a fresh CLI process started BY the very Pi bash-tool call it's attaching to (a session forking a child from inside its own turn) — going through `resume()` there would kill the bridge currently running the call itself.

```ts
const self = await Alineo.attach(process.env.ALINEO_SANDBOX_ID!, {
  adapter,
  name: "my-session",
});
const child = await self.spawn("./agents/worker.json");
```

***

## Spawning child agents [#spawning-child-agents]

### agent.spawn() [#agentspawn]

```ts
spawn(childSpecPath: string, opts?: { spawnDepth?: number; maxAgents?: number }): Promise<Alineo>
```

Fork **this agent's own live sandbox** — filesystem, installed packages, checked-out state, everything currently on disk — into a brand-new independent sandbox running its own Pi bridge. Unlike `Alineo.load()` (always starts from a spec's own snapshot) or `agent.fork()`/`agent.clone()` (Pi's own conversation-branching — same container, same bridge, new session branch), this is sandbox-level forking: the child sees exactly what this agent's sandbox sees right now, including uncommitted work. No install/setup steps run — the child inherits whatever is already installed on this agent's sandbox.

```ts
const child = await agent.spawn("./agents/worker.json", { spawnDepth: 2, maxAgents: 5 });
try {
  for await (const chunk of textOnly(child.prompt("Handle the auth module"))) {
    process.stdout.write(chunk);
  }
} finally {
  await child.close();
}
```

Refuses immediately unless this agent's own spawn-depth budget (`spawnDepth` in the spec, or `opts.spawnDepth` to override) is a positive integer — `0` means no budget left, `undefined` means spawning was never enabled. Each spawn force-decrements the budget (`current - 1`) into the child's env, regardless of what the child's own spec says.

`maxAgents` (spec field or `opts.maxAgents`) is a separate, optional ceiling on total descendants for this lineage, independent of nesting depth. Unset means uncapped for this dimension — only `spawnDepth` gates whether spawning is allowed at all. **Not** coordinated across sibling branches spawned in parallel; it's a per-lineage counter.

***

## Streaming [#streaming]

### agent.prompt() [#agentprompt]

```ts
prompt(
  message: string,
  opts?: {
    streamingBehavior?: "steer" | "followUp";
    inactivityTimeoutMs?: number;
    onPermission?: (req: PermissionRequest) => PermissionDecision | Promise<PermissionDecision>;
  },
): AgentStream
```

Send a message to Pi and stream the response. Pi maintains its own conversation context across calls within a session.

`onPermission` auto-resolves each `permission_request` on the stream with the handler's decision, instead of calling `resolvePermission()` by hand — the events still flow through the stream. Only meaningful when the spec sets `permissions`. See [Permission gate](/docs/agent/getting-started/permissions).

```ts
// Text only (most common):
for await (const chunk of textOnly(agent.prompt("Explain this repo"))) {
  process.stdout.write(chunk);
}

// Raw stream with tool events:
for await (const ev of agent.prompt("Run /workspace/script.py")) {
  if (ev.type === "text") process.stdout.write(ev.text);
  if (ev.type === "tool_start") console.log(`[tool] ${ev.toolName}`);
}
```

### agent.bash() [#agentbash]

```ts
bash(command: string): AgentStream
```

Run a shell command inside Pi's working context. Returns the same `AgentStream` type as `prompt()`, but not incrementally streamed — Pi returns bash output synchronously, so the full output arrives as a single `text` event once the command completes.

```ts
for await (const chunk of textOnly(agent.bash("ls -la /workspace"))) {
  process.stdout.write(chunk);
}
```

***

## Mid-flight control [#mid-flight-control]

### agent.steer() [#agentsteer]

```ts
async steer(message: string): Promise<void>
```

Redirect Pi's current response mid-flight. Pi acknowledges the instruction and adjusts its output. Best called after a short delay into a `prompt()` stream.

```ts
const stream = textOnly(agent.prompt("Write a very long essay..."));
setTimeout(() => agent.steer("Stop — give me 3 bullet points instead."), 1500);
for await (const chunk of stream) process.stdout.write(chunk);
```

### agent.followUp() [#agentfollowup]

```ts
async followUp(message: string): Promise<void>
```

Queue a message for Pi to process after it finishes the current task. Pi receives the message as a new turn in the same session once the active response completes.

### agent.abort() [#agentabort]

```ts
async abort(): Promise<void>
```

Cancel Pi's current operation. The in-flight `prompt()` stream ends with whatever was generated before the abort. Any pending permission requests are auto-rejected (`steer()` leaves them open).

***

## Permissions & approvals [#permissions--approvals]

Active only when `AgentSpec.permissions` is set to something other than `"auto"`. See [Permission gate](/docs/agent/getting-started/permissions) for the full model.

### agent.resolvePermission() [#agentresolvepermission]

```ts
async resolvePermission(requestId: string, decision: PermissionDecision): Promise<void>
```

Answer a `permission_request` event. `PermissionDecision` is `{ kind: "once" }`, `{ kind: "always" }`, or `{ kind: "reject"; feedback?: string }`. `always` / `reject` also clear every other still-pending request for the same tool.

```ts
for await (const ev of agent.prompt("Refactor auth")) {
  if (ev.type === "permission_request") {
    await agent.resolvePermission(ev.requestId, { kind: "once" });
  }
}
```

### agent.listPendingPermissions() [#agentlistpendingpermissions]

```ts
async listPendingPermissions(): Promise<PendingPermission[]>
```

Tool calls currently paused awaiting a decision — each `{ requestId, tool, target, title, since }`. Useful after reconnecting to a session to discover approvals still outstanding.

### agent.pendingEgressRequests() [#agentpendingegressrequests]

```ts
pendingEgressRequests(): EgressRequest[]
```

Outbound network requests to `approval: "hold"` hosts currently waiting for an `onEgressRequest` decision — each `{ host, since }`. Empty unless the spec has held credential bindings.

### agent.egressGate [#agentegressgate]

```ts
readonly egressGate?: EgressApprovalGate
```

The host-side listener that holds egress to `approval: "hold"` hosts until `onEgressRequest` approves. Present only when the spec has held bindings; started on `load()`, stopped on `close()`.

***

## Session management [#session-management]

### agent.newSession() [#agentnewsession]

```ts
async newSession(): Promise<void>
```

Reset Pi's conversation context. Pi forgets all prior messages. The sandbox filesystem is unchanged — files written in previous turns remain.

### agent.clone() [#agentclone]

```ts
async clone(): Promise<{ cancelled: boolean }>
```

Branch the current Pi session at the current position, creating a new session file. Returns whether the clone was cancelled.

### agent.fork() [#agentfork]

```ts
async fork(entryId: string): Promise<{ text: string; cancelled: boolean }>
```

Branch from a specific message entry in the conversation history. `entryId` comes from `getForkMessages()` (see below), not `getMessages()` — `PiMessage` has no `id`/`entryId` field. Returns the text of the forked message and whether it was cancelled.

```ts
const points = await agent.getForkMessages();
if (points.length > 0) {
  const forked = await agent.fork(points[0].entryId);
  console.log(`Forked from: "${forked.text.slice(0, 60)}"`);
}
```

### agent.switchSession() [#agentswitchsession]

```ts
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }>
```

Switch Pi to a different session file on disk.

### agent.getMessages() [#agentgetmessages]

```ts
async getMessages(): Promise<PiMessage[]>
```

Retrieve Pi's full conversation history for the current session.

```ts
const messages = await agent.getMessages();
console.log(`${messages.length} messages in session`);
```

***

## Model control [#model-control]

### agent.setModel() [#agentsetmodel]

```ts
async setModel(provider: string, modelId: string): Promise<PiModel>
```

Switch Pi to a specific model. The model must be in Pi's configured model list. Returns the activated `PiModel`.

### agent.cycleModel() [#agentcyclemodel]

```ts
async cycleModel(): Promise<{ model: PiModel; thinkingLevel: ThinkingLevel; isScoped: boolean } | null>
```

Cycle Pi to the next configured model. Returns the new model info, or `null` if only one model is configured.

### agent.getAvailableModels() [#agentgetavailablemodels]

```ts
async getAvailableModels(): Promise<PiModel[]>
```

List all models available to Pi under the current provider configuration.

### agent.setThinkingLevel() [#agentsetthinkinglevel]

```ts
async setThinkingLevel(level: ThinkingLevel): Promise<void>
```

Set Pi's reasoning level. Only effective on models that support extended thinking. `level` is `"none" | "low" | "medium" | "high"`.

### agent.cycleThinkingLevel() [#agentcyclethinkinglevel]

```ts
async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null>
```

Cycle Pi's thinking level. Returns `null` if the current model doesn't support thinking.

***

## Reliability [#reliability]

### agent.setAutoRetry() [#agentsetautoretry]

```ts
async setAutoRetry(enabled: boolean): Promise<void>
```

Enable or disable Pi's automatic retry on transient errors (429, 500, 502, 503, 504). Auto-retry is **on by default**: 3 attempts with exponential backoff (2 s / 4 s / 8 s).

Disable it when you want to handle errors yourself by observing `auto_retry_start` and `auto_retry_end` events in the stream:

```ts
await agent.setAutoRetry(false);

for await (const ev of agent.prompt("Run the test suite.")) {
  if (ev.type === "text") process.stdout.write(ev.text);
  if (ev.type === "auto_retry_start") {
    console.error(
      `[retry] attempt ${ev.attempt}/${ev.maxAttempts} in ${ev.delayMs}ms — ${ev.errorMessage}`,
    );
  }
  if (ev.type === "auto_retry_end" && !ev.success) {
    console.error(`[retry] failed after ${ev.attempt} attempts: ${ev.finalError}`);
  }
}
```

### agent.abortRetry() [#agentabortretry]

```ts
async abortRetry(): Promise<void>
```

Abort an in-progress auto-retry immediately. Pi stops waiting and fails the current operation, emitting `auto_retry_end` with `success: false`.

### agent.abortBash() [#agentabortbash]

```ts
async abortBash(): Promise<void>
```

Abort a currently-executing bash command without cancelling the whole prompt. No-op when no bash command is running.

***

## Session inspection [#session-inspection]

### agent.getSessionStats() [#agentgetsessionstats]

```ts
async getSessionStats(): Promise<SessionStats>
```

Retrieve token usage, cost, and message counts for the current session.

```ts
const stats = await agent.getSessionStats();
console.log(`tokens: ${stats.tokens.total}, cost: $${stats.cost.toFixed(6)}`);
if (stats.contextUsage) {
  console.log(`context: ${stats.contextUsage.percent.toFixed(1)}% full`);
}
```

See [SessionStats](#sessionstats) for the full type.

### agent.getLastAssistantText() [#agentgetlastassistanttext]

```ts
async getLastAssistantText(): Promise<string | null>
```

Retrieve the text of Pi's most recent assistant response without needing to iterate the stream. Returns `null` if Pi hasn't responded yet in the current session.

### agent.getForkMessages() [#agentgetforkmessages]

```ts
async getForkMessages(): Promise<{ entryId: string; text: string }[]>
```

List the fork entry points available in the current session. Each entry has `entryId` (suitable for passing to `fork()`) and `text` (the message content at that point).

```ts
const points = await agent.getForkMessages();
for (const p of points) {
  console.log(`${p.entryId}: "${p.text.slice(0, 60)}"`);
}
```

### agent.getCommands() [#agentgetcommands]

```ts
async getCommands(): Promise<PiSlashCommand[]>
```

List Pi's available slash commands, including extensions, prompt templates, and skills. Returns `PiSlashCommand[]`.

```ts
const cmds = await agent.getCommands();
for (const cmd of cmds) {
  console.log(`/${cmd.name} [${cmd.source}]${cmd.description ? ` — ${cmd.description}` : ""}`);
}
```

### agent.setSessionName() [#agentsetsessionname]

```ts
async setSessionName(name: string): Promise<void>
```

Set a display name for the current Pi session.

### agent.exportHtml() [#agentexporthtml]

```ts
async exportHtml(outputPath?: string): Promise<{ path: string }>
```

Export a static HTML transcript of the current session to the sandbox filesystem. Returns the container path of the generated file. Use `agent.sandbox.readFile(path)` to retrieve the contents.

```ts
const { path } = await agent.exportHtml();
const html = await agent.sandbox.readFile(path);
```

***

## Advanced control [#advanced-control]

### agent.setSteeringMode() [#agentsetsteeringmode]

```ts
async setSteeringMode(mode: "all" | "one-at-a-time"): Promise<void>
```

Control how Pi processes queued steering messages. `"all"` applies all queued steers at once; `"one-at-a-time"` applies them sequentially between turns.

### agent.setFollowUpMode() [#agentsetfollowupmode]

```ts
async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise<void>
```

Control how Pi processes queued follow-up messages. `"all"` sends all queued follow-ups at once; `"one-at-a-time"` sends them sequentially.

***

## Context management [#context-management]

### agent.setAutoCompaction() [#agentsetautocompaction]

```ts
async setAutoCompaction(enabled: boolean): Promise<void>
```

Enable or disable Pi's automatic context compaction.

### agent.compact() [#agentcompact]

```ts
async compact(customInstructions?: string): Promise<CompactResult>
```

Manually trigger Pi's context compaction. Returns a `CompactResult`: `{ summary, firstKeptEntryId, tokensBefore, estimatedTokensAfter }`.

***

## Environment and debugging [#environment-and-debugging]

### agent.setEnv() [#agentsetenv]

```ts
async setEnv(vars: Record<string, string>): Promise<void>
```

Merge `vars` into the sandbox environment. Writes to `/etc/alineo-env` inside the container and restarts Pi so it picks up the new values. Awaits Pi's readiness before returning.

```ts
await agent.setEnv({ DATABASE_URL: "postgres://...", DEBUG: "1" });
```

### agent.getLogs() [#agentgetlogs]

```ts
async getLogs(): Promise<string>
```

Retrieve the last 200 bridge log entries as a plain text string. Useful for debugging Pi startup or RPC issues.

### agent.close() [#agentclose]

```ts
async close(): Promise<void>
```

Delete the sandbox container and release all resources. Always call in a `finally` block.

```ts
try {
  // ...
} finally {
  await agent.close();
}
```

***

## Properties [#properties]

### agent.sandbox [#agentsandbox]

```ts
readonly sandbox: SandboxHandle
```

Direct access to the underlying `SandboxHandle` — the full [Core SDK Sandbox API](/docs/core/concepts/sandboxes), bypassing Pi. Use this to read or write files, run shell commands, or inspect the container independently of Pi.

```ts
await agent.sandbox.writeFile("/workspace/input.csv", data);
const { stdout } = await agent.sandbox.exec("wc -l /workspace/input.csv");
const output = await agent.sandbox.readFile("/workspace/output.txt");
```

### agent.sandboxId [#agentsandboxid]

```ts
readonly sandboxId: string
```

OpenSandbox container ID for this agent's sandbox.

### agent.name [#agentname]

```ts
readonly name: string
```

Name from the agent spec.

### agent.fromSnapshot [#agentfromsnapshot]

```ts
readonly fromSnapshot: boolean
```

`true` when this agent was restored from a cached snapshot (fast path). `false` on first load or after `{ rebuild: true }`.

***

## AgentSpec [#agentspec]

The JSON shape of an agent spec file. Pass the path to `Alineo.load(specPath)`.

```ts
import type { AgentSpec } from "alineo";
```

| Field                  | Type                                                 | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ---------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                 | `string`                                             | Unique identifier. Used as the sandbox session name.                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `cli`                  | `"pi"`                                               | CLI to run. Only `"pi"` is supported.                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `cliVersion`           | `string?`                                            | npm version specifier for the Pi CLI (e.g. `"1.2.3"`, `"^1.2.0"`, or a dist-tag like `"latest"`). Passed to `npm install -g @earendil-works/pi-coding-agent@<cliVersion>`. Omit to install whatever npm resolves as latest. Included in the setup-hash cache key, so changing it forces a fresh Pi CLI install.                                                                                                                                                                        |
| `model`                | `string?`                                            | Model ID passed to Pi via `--model`.                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `provider`             | `string?`                                            | AI provider passed via `--provider`. Omit for a direct Google API key.                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `packages`             | `string[]?`                                          | APT packages to install before Pi (e.g. `["python3", "git"]`).                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `env`                  | `Record<string, string \| CredentialEnvBinding>?`    | Env vars for the sandbox. Values may reference host env: `"${MY_VAR}"`. A value can also be a `CredentialEnvBinding` (`{ credential, host, injection, approval? }`) — that key never becomes a container env var; it's registered with the credential broker instead. `approval: "hold"` holds egress to `host` until approved. See [Credentials](/docs/core/concepts/credentials) and [Permission gate](/docs/agent/getting-started/permissions#holding-network-egress-for-approval). |
| `resources`            | `{ cpu: string; memory: string; gpu? }?`             | Container resource limits. Falls back to `alineo.config.json` defaults.                                                                                                                                                                                                                                                                                                                                                                                                                |
| `setup`                | `SetupStep[]?`                                       | Workspace setup steps — run after Pi install, baked into the snapshot.                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `permissions`          | `"auto" \| "ask" \| "readonly" \| PermissionPolicy?` | Human-in-the-loop tool-call gate. Omit (or `"auto"`) for the current behavior — no gate. See [Permission gate](/docs/agent/getting-started/permissions).                                                                                                                                                                                                                                                                                                                               |
| `title`                | `string?`                                            | Human-readable display name.                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `description`          | `string?`                                            | Short description of the agent.                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `author`               | `string?`                                            | Author name.                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `categories`           | `string[]?`                                          | Arbitrary category tags.                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `metadata`             | `Record<string,string>?`                             | Not read anywhere in `alineo`; has no effect on the sandbox.                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `registryDependencies` | `string[]?`                                          | Other agent spec URLs. Not read by `alineo` itself — used by `alineo add`, which fetches and saves each one first, depth-first.                                                                                                                                                                                                                                                                                                                                                        |
| `spawnDepth`           | `number?`                                            | Nesting-depth budget for `agent.spawn()`, force-decremented into each child's env. Non-negative integer. Required (directly or via `--depth`/`opts.spawnDepth`) for `agent.spawn()` to be allowed at all.                                                                                                                                                                                                                                                                              |
| `maxAgents`            | `number?`                                            | Optional cap on total descendants for this lineage, independent of `spawnDepth`. Non-negative integer. Unset means uncapped — unlike `spawnDepth`, omitting this doesn't disable spawning.                                                                                                                                                                                                                                                                                             |

### Example [#example]

```json
{
  "$schema": "https://registry.alineo.tech/spec/agent.json",
  "name": "my-agent",
  "cli": "pi",
  "model": "gemini-flash-latest",
  "packages": ["python3", "git"],
  "env": { "GEMINI_API_KEY": "${GEMINI_API_KEY}" },
  "resources": { "cpu": "1000m", "memory": "2Gi" },
  "setup": [
    { "name": "Clone repo", "run": "git clone https://github.com/owner/repo /workspace" },
    { "name": "Install deps", "run": "npm ci", "cwd": "/workspace" }
  ]
}
```

***

## SetupStep [#setupstep]

A single workspace setup step.

```ts
import type { SetupStep } from "alineo";
```

| Field  | Type      | Required | Description                                                        |
| ------ | --------- | -------- | ------------------------------------------------------------------ |
| `name` | `string`  | yes      | Human-readable label, shown in logs and included in the setup hash |
| `run`  | `string`  | yes      | Bash command to execute                                            |
| `cwd`  | `string?` | no       | Working directory. Runs the command as `cd <cwd> && <run>`         |

See [Workspace setup](/docs/agent/getting-started/workspace-setup) for details and examples.

***

## AgentStream [#agentstream]

```ts
type AgentStream = AsyncIterable<AgentEvent>;
```

Returned by `agent.prompt()` and `agent.bash()`. Iterate with `for await` to receive events as Pi generates them:

```ts
for await (const ev of agent.prompt("...")) {
  // ev is AgentEvent
}
```

Use `textOnly(stream)` to filter to just the text chunks.

***

## AgentEvent [#agentevent]

```ts
type AgentEvent =
  | { type: "text"; text: string }
  | { type: "tool_start"; toolCallId: string; toolName: string; args: unknown }
  | { type: "tool_update"; toolCallId: string; toolName: string; partialResult: unknown }
  | { type: "tool_end"; toolCallId: string; toolName: string; result: unknown; isError: boolean }
  | { type: "extension_ui"; method: string; params: unknown; isDialog: boolean; requestId?: string }
  | { type: "permission_request"; requestId: string; tool: string; target: string; title: string }
  | { type: "permission_resolved"; requestId: string; decision: PermissionDecision }
  | {
      type: "auto_retry_start";
      attempt: number;
      maxAttempts: number;
      delayMs: number;
      errorMessage: string;
    }
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
  | { type: "agent_start" }
  | { type: "agent_end"; messages: unknown[] }
  | { type: "turn_start"; turnIndex: number; timestamp: number }
  | { type: "turn_end"; turnIndex: number; message: unknown; toolResults: unknown[] }
  | { type: "message_start"; message: unknown }
  | { type: "message_update"; message: unknown; delta: unknown }
  | { type: "message_end"; message: unknown }
  | { type: "queue_update"; steering: string[]; followUp: string[] }
  | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
  | {
      type: "compaction_end";
      reason: "manual" | "threshold" | "overflow";
      result: {
        summary: string;
        firstKeptEntryId: string;
        tokensBefore: number;
        estimatedTokensAfter: number;
        details: unknown;
      } | null;
      aborted: boolean;
      willRetry: boolean;
    }
  | { type: "extension_error"; extensionPath: string; event: string; error: string };
```

See [Streaming & tool events](/docs/agent/getting-started/streaming) for usage examples.

***

## textOnly() [#textonly]

```ts
function textOnly(stream: AgentStream): AsyncIterable<string>;
```

Filter an `AgentStream` to just `text` events, yielding the `text` string of each. Equivalent to the old `PromptStream` behavior.

```ts
import { textOnly } from "alineo";

for await (const chunk of textOnly(agent.prompt("Hello"))) {
  process.stdout.write(chunk);
}
```

***

## SessionStats [#sessionstats]

```ts
import type { SessionStats } from "alineo";
```

Returned by `agent.getSessionStats()`.

| Field               | Type      | Description                                         |
| ------------------- | --------- | --------------------------------------------------- |
| `sessionId`         | `string`  | Pi's internal session identifier                    |
| `sessionFile`       | `string?` | Path to the session file on disk                    |
| `userMessages`      | `number`  | Number of user turns                                |
| `assistantMessages` | `number`  | Number of assistant turns                           |
| `toolCalls`         | `number`  | Total tool calls made                               |
| `toolResults`       | `number`  | Total tool results received                         |
| `totalMessages`     | `number`  | Sum of all messages                                 |
| `tokens.input`      | `number`  | Input tokens consumed                               |
| `tokens.output`     | `number`  | Output tokens generated                             |
| `tokens.cacheRead`  | `number`  | Tokens read from prompt cache                       |
| `tokens.cacheWrite` | `number`  | Tokens written to prompt cache                      |
| `tokens.total`      | `number`  | Total tokens (all categories)                       |
| `cost`              | `number`  | Estimated cost in USD                               |
| `contextUsage`      | `object?` | `{ tokens, contextWindow, percent }` — context fill |

***

## PiSlashCommand [#pislashcommand]

```ts
import type { PiSlashCommand } from "alineo";
```

Returned by `agent.getCommands()`.

| Field         | Type                                 | Description                                      |
| ------------- | ------------------------------------ | ------------------------------------------------ |
| `name`        | `string`                             | Invokable command name (without the leading `/`) |
| `description` | `string?`                            | Human-readable description                       |
| `source`      | `"extension" \| "prompt" \| "skill"` | Where the command comes from                     |
| `sourceInfo`  | `unknown`                            | Metadata about the owning resource               |

***

## alineo.config.json [#alineoconfigjson]

`alineo` reads `alineo.config.json` from the current working directory to configure the client. All fields are optional.

```json title="alineo.config.json"
{
  "serverUrl": "http://127.0.0.1:8080",
  "apiKey": "",
  "adapterPath": "./.alineo/ledger.db",
  "useServerProxy": true,
  "agentsDir": "./agents",
  "defaults": {
    "resources": { "cpu": "1000m", "memory": "1Gi" }
  }
}
```

| Field                | Default                           | Description                                                                |
| -------------------- | --------------------------------- | -------------------------------------------------------------------------- |
| `serverUrl`          | `http://127.0.0.1:8080`           | OpenSandbox server URL                                                     |
| `apiKey`             | `""`                              | OpenSandbox API key (empty for local dev)                                  |
| `adapterPath`        | `./.alineo/ledger.db`             | Path to the SQLite ledger database                                         |
| `useServerProxy`     | `true`                            | Route execd traffic through the server. Required when using `alineo init`. |
| `agentsDir`          | `./agents`                        | Directory containing agent spec files                                      |
| `defaults.resources` | `{ cpu: "1000m", memory: "1Gi" }` | Default resource limits when the spec omits `resources`                    |

`alineo init` writes this file automatically.


---

# API Reference
URL: /docs/agent/api-reference

Complete reference for the Alineo SDK.



<Cards>
  <Card href="/docs/agent/api-reference/agent" title="Alineo" description="Complete reference for the Alineo class, AgentSpec, AgentStream, and all Pi RPC commands." />
</Cards>


---

# Getting Started
URL: /docs/agent/getting-started

Load an agent spec, send a prompt, and understand snapshotting, workspace setup, and streaming.



<Cards>
  <Card href="/docs/agent/getting-started/quickstart" title="Quick start" description="Load an agent spec, send a prompt, and stream the response in minutes." />

  <Card href="/docs/agent/getting-started/snapshotting" title="Snapshotting" description="How Alineo.load() caches the Pi install so subsequent loads take seconds instead of minutes." />

  <Card href="/docs/agent/getting-started/workspace-setup" title="Workspace setup" description="Declarative bash steps that run after Pi install and are baked into the snapshot." />

  <Card href="/docs/agent/getting-started/streaming" title="Streaming & tool events" description="Observe Pi's text output, tool calls, and lifecycle events in real time." />

  <Card href="/docs/agent/getting-started/reliability" title="Reliability & error recovery" description="Handle transient API errors with auto-retry, observe retry events, and abort mid-flight bash commands." />

  <Card href="/docs/agent/getting-started/session-inspection" title="Session inspection & control" description="Inspect token usage, retrieve session history, list available commands, and export HTML transcripts." />

  <Card href="/docs/agent/getting-started/permissions" title="Permission gate (human-in-the-loop)" description="Pause tool calls for human approval, restrict the toolset, and hold network egress until someone approves." />
</Cards>


---

# Permission gate (human-in-the-loop)
URL: /docs/agent/getting-started/permissions

Pause an agent's tool calls for human approval — a mode shorthand or a full per-tool policy, plus a gate that holds outbound network egress until someone approves.



By default a sandboxed Pi agent runs its tools without asking (`pi --mode rpc --approve`). `AgentSpec.permissions` puts a gate in front of every tool call: some run free, some pause and wait for a human, some are refused outright.

Enforcement runs **inside the Pi process** (its `tool_call` hook). It stops a misbehaving *model* — it is not a barrier against a process with shell access inside the sandbox actively working around it. For that, hold the network itself: see [Holding network egress for approval](#holding-network-egress-for-approval) below.

## Modes [#modes]

The quickest form is a string:

```json
{
  "name": "my-agent",
  "cli": "pi",
  "permissions": "readonly"
}
```

| Mode         | Behavior                                                                                                                                                                                                   |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"auto"`     | Never ask. Identical to omitting `permissions` — no gate is loaded at all. (Default.)                                                                                                                      |
| `"ask"`      | Pause before **every** tool call.                                                                                                                                                                          |
| `"readonly"` | Restrict the model's toolset to the read-only tools (`read`, `grep`, `find`, `ls`) via Pi's `setActiveTools`, so it never sees `write` / `edit` / `bash`. Any `bash` left reachable is `classify`-triaged. |

## Full policy [#full-policy]

For anything finer, `permissions` takes an object:

```jsonc
{
  "permissions": {
    "default": "ask",
    "rules": [
      { "tool": "read", "action": "allow" },
      { "tool": "grep", "action": "allow" },
      { "tool": "bash", "action": "classify" },
      { "tool": "bash", "pattern": "*rm -rf*", "action": "deny" },
      { "tool": "write", "pattern": "*/package.json", "action": "ask" },
    ],
  },
}
```

| `PermissionPolicy` field | Type               | Description                                                                                                                                                                  |
| ------------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `default`                | `PermissionAction` | Action when no rule matches. Defaults to `"ask"`.                                                                                                                            |
| `rules`                  | `PermissionRule[]` | Evaluated in order; the **last** matching rule wins.                                                                                                                         |
| `disabledTools`          | `string[]`         | Tools the agent may never call. Stripped from the model's tool list at session start, with an unconditional `deny` backstop for anything registered later (SDK / MCP tools). |
| `restrictToTools`        | `string[]`         | If set, the **only** tools the model may see. An allowlist (`disabledTools` is a denylist). `"readonly"` expands to this.                                                    |

### PermissionRule [#permissionrule]

| Field     | Type                   | Description                                                                                                                                                                                                                                                                            |
| --------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tool`    | `string`               | Tool name or glob (`*` = any run of chars, `?` = one). `"bash"`, `"write"`, `"*"`.                                                                                                                                                                                                     |
| `pattern` | `string?`              | Glob matched against a tool-specific target: the command for `bash`, the path for `read` / `write` / `edit`, the query for `grep`. **Anchored** — `"git *"` matches `"git status"` but not `"x && git status"`; use `"*git*"` for a substring match. Omit to match any call to `tool`. |
| `action`  | `PermissionAction`     | What to do on a match.                                                                                                                                                                                                                                                                 |
| `limit`   | `{ count, windowMs }?` | `rate_limit` only: the ceiling and rolling window.                                                                                                                                                                                                                                     |

### Actions [#actions]

| Action       | Effect                                                                                                                                                                                                                                                                                                                                                                                      |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allow`      | Run it, no prompt.                                                                                                                                                                                                                                                                                                                                                                          |
| `ask`        | Pause, emit a `permission_request`, wait for a human decision.                                                                                                                                                                                                                                                                                                                              |
| `deny`       | Refuse, with a reason the model reads and can adjust to.                                                                                                                                                                                                                                                                                                                                    |
| `rate_limit` | Allow up to `limit.count` matching calls per `limit.windowMs`, then deny.                                                                                                                                                                                                                                                                                                                   |
| `classify`   | Best-effort read-vs-write triage (today: `bash` / `powershell` only). Splits the command on `&&` / `\|\|` / `;` / `\|` / newline and checks each part against a built-in safe-reader list (`ls`, `cat`, `grep`, `git status`, …). All parts read-only → `allow`; anything unrecognised, a redirect, `sudo`, `rm` → falls through to `ask`. For any other tool, `classify` behaves as `ask`. |

## Resolving a request from the stream [#resolving-a-request-from-the-stream]

When the gate pauses a call it emits a `permission_request` event. Answer it with `agent.resolvePermission()`:

```ts
for await (const ev of agent.prompt("Refactor the auth module")) {
  if (ev.type === "text") process.stdout.write(ev.text);
  else if (ev.type === "permission_request") {
    console.log(`${ev.tool}: ${ev.target}`);
    await agent.resolvePermission(ev.requestId, { kind: "once" });
  }
}
```

`PermissionDecision`:

| `kind`     | Effect                                                                                                                                         |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `"once"`   | Allow this one call.                                                                                                                           |
| `"always"` | Allow this call and auto-allow every other still-pending request for the same tool. Does **not** persist past the session.                     |
| `"reject"` | Block the call. `feedback` (if given) becomes the reason the model reads. Every other still-pending request for the same tool is rejected too. |

```ts
await agent.resolvePermission(ev.requestId, {
  kind: "reject",
  feedback: "No package installs — use the standard library.",
});
```

## Handler form — `prompt({ onPermission })` [#handler-form--prompt-onpermission-]

To skip the hand-wired loop, pass an `onPermission` handler. It's called for each request and its return value auto-resolves it; the `permission_request` / `permission_resolved` events still flow through the stream.

```ts
import { Alineo, type PermissionRequest, type PermissionDecision } from "alineo";

async function onPermission(req: PermissionRequest): Promise<PermissionDecision> {
  if (/\b(pip|npm|apt)\b.*\binstall\b/.test(req.target)) {
    return { kind: "reject", feedback: "No installs in this run." };
  }
  return { kind: "once" };
}

for await (const ev of agent.prompt("Do the task", { onPermission })) {
  if (ev.type === "text") process.stdout.write(ev.text);
}
```

## Inspecting what's paused [#inspecting-whats-paused]

`agent.listPendingPermissions()` returns the tool calls currently waiting — useful after reconnecting to a session:

```ts
for (const p of await agent.listPendingPermissions()) {
  console.log(`${p.tool}: ${p.target}  (waiting since ${new Date(p.since).toISOString()})`);
  await agent.resolvePermission(p.requestId, { kind: "once" });
}
```

An operator reconnecting over `/permission-stream` is replayed the outstanding requests, and the auto-deny timeout is suspended while an operator is attached.

## Audit trail [#audit-trail]

Every request and resolution is written to the ledger as `permission_requested` / `permission_resolved` — **metadata only, never raw tool arguments**. Read it back with any storage adapter or `alineo logs <session>`.

```ts
for (const e of await adapter.readAll(agent.name, agent.sandboxId)) {
  if (e.event === "permission_requested" || e.event === "permission_resolved") {
    console.log(e.event, e.payload);
  }
}
```

## Lifecycle notes [#lifecycle-notes]

* `abort()` auto-rejects any pending approvals; `steer()` leaves them open.
* `Alineo.resume()` closes out approvals that were dropped when the previous Pi process ended.
* Ambient user extensions (`settings.json`, `.pi/extensions/`) still load but cannot bypass the gate — Pi's hook semantics are first-block-wins.
* Fully durable pauses across `sb.pause()` / checkpoint need an upstream Pi change and are tracked as a follow-up.

See the runnable [`examples/human-in-the-loop`](https://github.com/DrejT/alineo/tree/main/examples/human-in-the-loop) for an end-to-end walkthrough.

***

## Holding network egress for approval [#holding-network-egress-for-approval]

The permission gate above stops a misbehaving model. To gate a *host* regardless of what runs inside the sandbox, mark a credential binding in `AgentSpec.env` with `approval: "hold"`:

```jsonc
{
  "env": {
    "NVIDIA_API_KEY": "${NVIDIA_API_KEY}",
    "GITHUB_TOKEN": {
      "credential": "${GITHUB_TOKEN}",
      "host": "api.github.com",
      "injection": { "type": "header", "name": "Authorization" },
      "approval": "hold",
    },
  },
}
```

On `Alineo.load()`:

1. The sandbox starts `defaultAction: "allow"` with a single `deny` rule for `api.github.com` — everything else, including the agent's own model traffic, works normally.
2. The `GITHUB_TOKEN` credential is **not registered in the vault at all** (the vault refuses a binding whose host isn't allowed).
3. The first outbound request to the held host is denied at the sidecar, which fires a webhook. That pauses the request and calls the `onEgressRequest` handler you passed to `Alineo.load()`.
4. On approval the gate opens the egress rule (`sb.egress.patch`) and **then** registers the credential — so the secret literally does not exist inside the sandbox until a human approves.

```ts
import { Alineo, type EgressRequest, type EgressDecision } from "alineo";

async function onEgressRequest(req: EgressRequest): Promise<EgressDecision> {
  return req.host === "api.github.com" ? "allow-once" : "deny";
}

const agent = await Alineo.load(spec, { adapter, onEgressRequest });
```

| `EgressDecision` | Effect                                                                         |
| ---------------- | ------------------------------------------------------------------------------ |
| `"allow-once"`   | Open the host and inject the credential; both are reversed when the turn ends. |
| `"allow-always"` | Open the host and inject the credential permanently (for the agent's life).    |
| `"deny"`         | Leave the host denied. The model sees the failed request and moves on.         |

Loading a spec that has a `"hold"` binding **without** an `onEgressRequest` handler throws.

* Enforcement is entirely **out-of-process at the egress sidecar** — a compromised in-sandbox agent cannot reach a held host until a human approves, no matter what it does inside the sandbox.
* `agent.pendingEgressRequests()` lists what's waiting; `agent.egressGate` (an `EgressApprovalGate`) is exposed for direct control. The listener is started on `load()` and stopped on `close()`.
* Requests and resolutions land on the ledger as `PermissionRequested` / `PermissionResolved` with `tool: "network"`.
* The sidecar reaches the host process at the Docker bridge gateway (`172.17.0.1`) by default — override with `ALINEO_EGRESS_APPROVAL_HOST` for other network topologies.

<Callout title="Deferred">
  The deny-webhook signal is not yet unified into the Pi tool-permission stream, so network
  approvals do **not** appear in `listPendingPermissions()` or the chat UI alongside tool
  permissions. There is also no automatic re-run of the request that hit the denial — the model
  retries on its own (the window is effectively instant). Both are tracked follow-ups.
</Callout>

See the runnable [`examples/agent-egress-approval`](https://github.com/DrejT/alineo/tree/main/examples/agent-egress-approval).


---

# Quick start
URL: /docs/agent/getting-started/quickstart

Load an agent spec, send a prompt, and stream the response in minutes.



`alineo` wraps a [Pi](https://pi.ai) coding agent CLI in an OpenSandbox container and exposes it through a simple TypeScript API. The agent can read and write files inside the sandbox, run shell commands and Python scripts autonomously, and stream its responses back to the host.

<Steps>
  <Step>
    ### Install [#install]

    ```bash
    bun add alineo
    ```
  </Step>

  <Step>
    ### Start a local sandbox server [#start-a-local-sandbox-server]

    Run OpenSandbox locally. See [Core SDK — Installation](/docs/core/getting-started/installation) for the full setup.

    The quickest path:

    ```bash
    bunx alineo-cli init   # starts OpenSandbox in Docker, writes alineo.config.json
    ```
  </Step>

  <Step>
    ### Write an agent spec [#write-an-agent-spec]

    Create `agents/hello-agent.json`:

    ```json title="agents/hello-agent.json"
    {
      "$schema": "https://registry.alineo.tech/spec/agent.json",
      "name": "hello-agent",
      "cli": "pi",
      "model": "gemini-flash-latest",
      "packages": ["python3"],
      "env": {
        "GEMINI_API_KEY": "${GEMINI_API_KEY}"
      },
      "resources": { "cpu": "1000m", "memory": "2Gi" }
    }
    ```

    `${GEMINI_API_KEY}` is interpolated from `process.env.GEMINI_API_KEY` at load time.
  </Step>

  <Step>
    ### Load the agent and send a prompt [#load-the-agent-and-send-a-prompt]

    ```ts title="run.ts"
    import { Alineo, textOnly } from "alineo";
    import { SQLiteAdapter } from "@alineo-labs/sqlite";

    const adapter = new SQLiteAdapter("./.alineo/ledger.db");
    const spec = await Bun.file("./agents/hello-agent.json").json();
    const agent = await Alineo.load(spec, { adapter });
    console.log(`Sandbox: ${agent.sandboxId}  fromSnapshot=${agent.fromSnapshot}`);

    try {
      for await (const chunk of textOnly(
        agent.prompt("Write a Python script that prints the first 10 Fibonacci numbers. Run it."),
      )) {
        process.stdout.write(chunk);
      }
    } finally {
      await agent.close();
    }
    ```

    ```bash
    bun run.ts
    ```

    `Alineo.load()` logs timing for each phase. The first run installs the Pi CLI and checkpoints the sandbox — on the order of a minute. Subsequent runs restore from that snapshot in a few seconds:

    ```
    # First run
    [agent] starting sandbox (hello-agent)...
    [agent] sandbox ready   12340ms (abc-123...)
    [agent] installing Pi CLI...
    [agent] Pi CLI ready    28000ms
    [agent] checkpointing...
    [agent] checkpoint done 10000ms
    [agent] starting bridge...
    [agent] bridge ready    3210ms
    [agent] total           54s

    # Second run
    [agent] restoring from snapshot...
    [agent] snapshot ready  1900ms (def-456...)
    [agent] starting bridge...
    [agent] bridge ready    2800ms
    [agent] total           5s (from snapshot)
    ```

    See [Snapshotting](/docs/agent/getting-started/snapshotting) for details.
  </Step>

  <Step>
    ### Read and write files [#read-and-write-files]

    `agent.sandbox` gives direct access to the underlying `SandboxHandle` — bypassing Pi entirely:

    ```ts
    // Host writes a file into the container
    await agent.sandbox.writeFile("/workspace/data.csv", csvContent);

    // Pi reads and processes it
    for await (const chunk of textOnly(agent.prompt("Summarize the CSV at /workspace/data.csv."))) {
      process.stdout.write(chunk);
    }

    // Host reads back whatever Pi wrote
    const result = await agent.sandbox.readFile("/workspace/summary.txt");

    // Host can also run shell commands directly
    const { stdout } = await agent.sandbox.exec("ls /workspace/");
    ```
  </Step>

  <Step>
    ### Session management [#session-management]

    ```ts
    // Clear Pi's conversation context (filesystem unchanged)
    await agent.newSession();

    // Update environment variables and restart Pi with the new values
    await agent.setEnv({ EXTRA_CONTEXT: "project-alpha" });
    ```
  </Step>
</Steps>

## Next steps [#next-steps]

* [Snapshotting](/docs/agent/getting-started/snapshotting) — how fast restores work and how to control the cache
* [Workspace setup](/docs/agent/getting-started/workspace-setup) — bake files and dependencies into the snapshot
* [Streaming & tool events](/docs/agent/getting-started/streaming) — observe Pi's tool calls in real time
* [Alineo API reference](/docs/agent/api-reference) — full reference for all methods and types


---

# Reliability & error recovery
URL: /docs/agent/getting-started/reliability

Handle transient API errors with auto-retry, observe retry events, and abort mid-flight bash commands.



Pi has built-in transient-error recovery that fires automatically when the AI provider returns a 429 (rate limit) or 5xx (server error). The `alineo` bridge surfaces this as observable events so you can build responsive UIs without writing retry logic yourself.

## Auto-retry [#auto-retry]

Auto-retry is **on by default**: Pi retries up to 3 times with exponential backoff (2 s → 4 s → 8 s). You don't need to configure anything for it to work.

When a transient error occurs mid-prompt, Pi pauses internally and emits `auto_retry_start`. After the delay it retries, and on completion emits `auto_retry_end`.

```ts
for await (const ev of agent.prompt("Run the full test suite.")) {
  switch (ev.type) {
    case "text":
      process.stdout.write(ev.text);
      break;

    case "auto_retry_start":
      console.warn(
        `[retry] attempt ${ev.attempt}/${ev.maxAttempts} — ` +
          `waiting ${ev.delayMs / 1000}s after: ${ev.errorMessage}`,
      );
      break;

    case "auto_retry_end":
      if (!ev.success) {
        console.error(`[retry] failed after ${ev.attempt} attempts: ${ev.finalError}`);
      }
      break;
  }
}
```

`auto_retry_start` and `auto_retry_end` are part of the `AgentEvent` discriminated union — they're always present in the stream, so no extra setup is required.

## Disabling auto-retry [#disabling-auto-retry]

If you want full control over when retries happen — for example to gate them on user confirmation — disable auto-retry and handle failures yourself:

```ts
await agent.setAutoRetry(false);

for await (const ev of agent.prompt("Deploy the application.")) {
  if (ev.type === "text") process.stdout.write(ev.text);

  if (ev.type === "auto_retry_end" && !ev.success) {
    // auto-retry is off, so this fires immediately on the first failure
    const shouldRetry = await askUser("API error. Retry?");
    if (shouldRetry) {
      await agent.setAutoRetry(true); // re-enable for the next prompt
    }
  }
}
```

Re-enable it at any time with `agent.setAutoRetry(true)`. The setting persists across prompts until changed.

## Aborting a retry in progress [#aborting-a-retry-in-progress]

If a retry is currently waiting (counting down the backoff delay), you can cancel it immediately:

```ts
// User clicks "Cancel retry"
await agent.abortRetry();
```

Pi fails the current operation immediately and emits `auto_retry_end` with `success: false`. No-op when no retry is pending.

## Aborting a bash command [#aborting-a-bash-command]

`agent.abortBash()` stops a currently-executing bash command without cancelling the whole prompt. Pi receives the abort, the bash result is marked as errored, and Pi continues with whatever it would do next (typically reporting the failure to the user):

```ts
// Show a stop button while Pi is running bash
const stream = agent.prompt("Run the build.");
let bashRunning = false;

for await (const ev of stream) {
  if (ev.type === "tool_start" && ev.toolName === "bash") {
    bashRunning = true;
    showStopButton(() => agent.abortBash());
  }
  if (ev.type === "tool_end" && ev.toolName === "bash") {
    bashRunning = false;
    hideStopButton();
  }
  if (ev.type === "text") process.stdout.write(ev.text);
}
```

`abortBash()` is a no-op when no bash command is running — safe to call speculatively.

## Summary [#summary]

| Method / Event        | What it does                                                   |
| --------------------- | -------------------------------------------------------------- |
| `setAutoRetry(true)`  | Enable auto-retry on transient errors (default)                |
| `setAutoRetry(false)` | Disable — handle `auto_retry_end` failures manually            |
| `abortRetry()`        | Cancel the current retry countdown; Pi fails immediately       |
| `abortBash()`         | Stop the running bash command; prompt continues                |
| `auto_retry_start`    | Fires when a retry attempt is about to begin                   |
| `auto_retry_end`      | Fires when the retry sequence completes (success or exhausted) |


---

# Session inspection & control
URL: /docs/agent/getting-started/session-inspection

Inspect token usage, retrieve session history, list available commands, and control how Pi processes queued messages.



The agent SDK exposes a set of methods for inspecting the current Pi session without streaming a new prompt. These are useful for building dashboards, debugging token usage, navigating session history, and managing how Pi handles queued messages.

## Token usage and cost [#token-usage-and-cost]

`agent.getSessionStats()` returns a snapshot of the current session's resource consumption:

```ts
const stats = await agent.getSessionStats();

console.log(`Session:       ${stats.sessionId}`);
console.log(`Messages:      ${stats.userMessages} user / ${stats.assistantMessages} assistant`);
console.log(`Tool calls:    ${stats.toolCalls}`);
console.log(`Tokens:        ${stats.tokens.input} in / ${stats.tokens.output} out`);
console.log(`Cache:         ${stats.tokens.cacheRead} read / ${stats.tokens.cacheWrite} write`);
console.log(`Cost:          $${stats.cost.toFixed(6)}`);

if (stats.contextUsage) {
  const { tokens, contextWindow, percent } = stats.contextUsage;
  console.log(`Context:       ${percent.toFixed(1)}% (${tokens} / ${contextWindow})`);
}
```

This is a synchronous read from Pi — it doesn't start a new prompt. Call it after any `prompt()` completes to track cumulative usage across a long session.

### Context window pressure [#context-window-pressure]

`contextUsage.percent` tells you how full Pi's context window is. Pi will auto-compact when it approaches the limit (see [Streaming — Compaction events](/docs/agent/getting-started/streaming#compaction-events)), but you can also monitor it proactively:

```ts
const stats = await agent.getSessionStats();
if ((stats.contextUsage?.percent ?? 0) > 80) {
  await agent.compact(); // manual compaction before it becomes automatic
}
```

## Retrieving the last response [#retrieving-the-last-response]

`agent.getLastAssistantText()` returns the text of Pi's most recent assistant message without opening a new stream:

```ts
const text = await agent.getLastAssistantText();
if (text) {
  console.log(`Last response: ${text.slice(0, 200)}`);
}
```

Returns `null` if Pi has not yet responded in the current session. Useful for building "copy last response" buttons or logging the final assistant message after a long autonomous run.

## Fork entry points [#fork-entry-points]

`agent.getForkMessages()` lists the entry points in the current session that can be used as `fork()` targets:

```ts
const points = await agent.getForkMessages();

for (const { entryId, text } of points) {
  console.log(`${entryId}  "${text.slice(0, 60)}"`);
}

// Fork from a specific point
if (points.length > 0) {
  const { text, cancelled } = await agent.fork(points[0].entryId);
  console.log(`Forked at: "${text.slice(0, 60)}" (cancelled: ${cancelled})`);
}
```

Each entry corresponds to a user turn in Pi's session history. Forking creates a new session branch at that point — useful for trying alternative responses or replaying from a known state.

## Available commands [#available-commands]

`agent.getCommands()` lists all slash commands Pi can understand in the current environment — including commands contributed by installed extensions, prompt templates, and skills:

```ts
const commands = await agent.getCommands();

for (const cmd of commands) {
  const source = cmd.source; // "extension" | "prompt" | "skill"
  const desc = cmd.description ?? "(no description)";
  console.log(`/${cmd.name} [${source}]  ${desc}`);
}
```

This is primarily useful when building a command palette or auto-complete UI on top of the agent.

## Naming sessions [#naming-sessions]

`agent.setSessionName()` sets a display name for the current Pi session. The name is stored as metadata in the session file — it doesn't affect the session ID or behaviour:

```ts
await agent.setSessionName(`audit-${new Date().toISOString().slice(0, 10)}`);
```

## Exporting an HTML transcript [#exporting-an-html-transcript]

`agent.exportHtml()` generates a static HTML file containing the full session transcript and writes it to the sandbox filesystem:

```ts
const { path } = await agent.exportHtml();
// path is a container-relative path, e.g. /root/pi-session-2024-01-15_abc123.html

// Read the file from the sandbox back to the host
const html = await agent.sandbox.readFile(path);

// Write it somewhere local
await Bun.write("./session-transcript.html", html);
```

You can optionally specify where the file should be written inside the container:

```ts
const { path } = await agent.exportHtml("/workspace/exports/session.html");
```

## Queue processing modes [#queue-processing-modes]

When `agent.followUp()` or `agent.steer()` are called while a prompt is in flight, Pi queues the message and processes it after the current turn. Two methods control how that queue is consumed.

### Steering mode [#steering-mode]

`agent.setSteeringMode()` controls how Pi applies queued steering messages:

* `"all"` (default) — Pi applies every queued steer at once when it processes the queue
* `"one-at-a-time"` — Pi applies steers one per turn, giving you finer control over the conversation

```ts
await agent.setSteeringMode("one-at-a-time");

const stream = agent.prompt("Write a very long document.");
setTimeout(() => agent.steer("Focus on section 3."), 1000);
setTimeout(() => agent.steer("Now add an executive summary."), 2000);

for await (const ev of stream) {
  if (ev.type === "text") process.stdout.write(ev.text);
  if (ev.type === "queue_update") {
    console.log(`\n[${ev.steering.length} steers pending]`);
  }
}
```

### Follow-up mode [#follow-up-mode]

`agent.setFollowUpMode()` mirrors `setSteeringMode()` but for follow-up messages queued with `agent.followUp()`:

```ts
await agent.setFollowUpMode("one-at-a-time");
```


---

# Snapshotting
URL: /docs/agent/getting-started/snapshotting

How Alineo.load() caches the Pi install so subsequent loads take seconds instead of minutes.



On the first call to `Alineo.load()`, the SDK installs the Pi CLI inside a `node:22` sandbox, runs any [workspace setup steps](/docs/agent/getting-started/workspace-setup), then checkpoints the container. On every subsequent call it restores from that snapshot — skipping the install entirely.

```
Load 1 (cold):   sandbox → Pi install → setup steps → checkpoint → bridge   ~90s
Load 2+ (warm):  snapshot restore → bridge                                   ~3s
```

## fromSnapshot [#fromsnapshot]

`agent.fromSnapshot` is `true` when the agent was restored from a snapshot:

```ts
// adapter: an IStorageAdapter — SQLiteAdapter or PostgresAdapter, see Quickstart
const spec = await Bun.file("./agents/my-agent.json").json();
const agent = await Alineo.load(spec, { adapter });
console.log(agent.fromSnapshot); // false on first load, true after
```

## Cache invalidation [#cache-invalidation]

The snapshot is keyed on a hash of the fields that affect the installed environment:

* `cli` — currently always `"pi"`
* `cliVersion` — pinned version or `"latest"`
* `packages` — APT packages sorted alphabetically
* `setup` — workspace setup steps (names, commands, working directories)

Any change to these fields automatically invalidates the snapshot and triggers a full rebuild on the next `Alineo.load()` call. Fields that don't affect the snapshot — `model`, `provider`, `env`, `resources` — never invalidate the cache.

## Force rebuild [#force-rebuild]

Pass `{ rebuild: true }` to force a full reinstall regardless of the cached snapshot:

```ts
const agent = await Alineo.load(spec, { adapter, rebuild: true });
```

This is useful after updating a package version that isn't tracked through the spec (e.g. a package installed by a setup step that fetches from the internet).

## How the snapshot store works [#how-the-snapshot-store-works]

Snapshot records are stored in `agent-snapshots.json` alongside the ledger database (`.alineo/agent-snapshots.json` by default). Each record maps `specName + setupHash` → `snapshotId`. On restore, `client.restoreSnapshot(snapshotId)` recreates the container from the saved image.

If the snapshot no longer exists on the OpenSandbox server (e.g. after a server restart), `Alineo.load()` detects the failure, logs `[agent] snapshot stale, rebuilding...`, and falls back to a full install automatically.


---

# Streaming & tool events
URL: /docs/agent/getting-started/streaming

Observe Pi's text output and tool calls in real time using AgentStream and AgentEvent.



`agent.prompt()` and `agent.bash()` return an `AgentStream` — an `AsyncIterable<AgentEvent>`. Each event is a discriminated union that tells you whether Pi is writing text or using a tool.

## AgentEvent [#agentevent]

```ts
type AgentEvent =
  | { type: "text"; text: string }
  | { type: "tool_start"; toolCallId: string; toolName: string; args: unknown }
  | { type: "tool_update"; toolCallId: string; toolName: string; partialResult: unknown }
  | { type: "tool_end"; toolCallId: string; toolName: string; result: unknown; isError: boolean }
  | { type: "extension_ui"; method: string; params: unknown; isDialog: boolean; requestId?: string }
  | { type: "permission_request"; requestId: string; tool: string; target: string; title: string }
  | { type: "permission_resolved"; requestId: string; decision: PermissionDecision }
  | {
      type: "auto_retry_start";
      attempt: number;
      maxAttempts: number;
      delayMs: number;
      errorMessage: string;
    }
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
  | { type: "agent_start" }
  | { type: "agent_end"; messages: unknown[] }
  | { type: "turn_start"; turnIndex: number; timestamp: number }
  | { type: "turn_end"; turnIndex: number; message: unknown; toolResults: unknown[] }
  | { type: "message_start"; message: unknown }
  | { type: "message_update"; message: unknown; delta: unknown }
  | { type: "message_end"; message: unknown }
  | { type: "queue_update"; steering: string[]; followUp: string[] }
  | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
  | {
      type: "compaction_end";
      reason: "manual" | "threshold" | "overflow";
      result: {
        summary: string;
        firstKeptEntryId: string;
        tokensBefore: number;
        estimatedTokensAfter: number;
        details: unknown;
      } | null;
      aborted: boolean;
      willRetry: boolean;
    }
  | { type: "extension_error"; extensionPath: string; event: string; error: string };
```

| Event                 | When it fires                                                                                                                             |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `text`                | Pi is writing a text chunk                                                                                                                |
| `tool_start`          | Pi has invoked a tool (e.g. `bash`, `write_file`)                                                                                         |
| `tool_update`         | Partial output from a long-running tool                                                                                                   |
| `tool_end`            | Tool completed — includes the full result and whether it errored                                                                          |
| `extension_ui`        | A Pi extension requested UI interaction (dialog auto-cancelled; forwarded for observability)                                              |
| `permission_request`  | The [permission gate](/docs/agent/getting-started/permissions) paused a tool call for approval — resolve with `agent.resolvePermission()` |
| `permission_resolved` | A `permission_request` was answered (by a caller, a batched decision, or the timeout)                                                     |
| `auto_retry_start`    | Pi is retrying after a transient error (429, 5xx)                                                                                         |
| `auto_retry_end`      | Retry sequence completed — `success` indicates whether it recovered                                                                       |
| `agent_start`         | Pi began processing the prompt                                                                                                            |
| `agent_end`           | Pi finished the full agent run (all turns complete); includes all messages                                                                |
| `turn_start`          | A new LLM turn began; includes `turnIndex` and `timestamp`                                                                                |
| `turn_end`            | A turn completed with its assistant message and tool results                                                                              |
| `message_start`       | A new assistant message began streaming                                                                                                   |
| `message_update`      | Streaming delta from an in-flight message; `delta` is the raw Pi event (text, thinking, tool call delta, etc.)                            |
| `message_end`         | An assistant message completed                                                                                                            |
| `queue_update`        | The steering/follow-up queue changed (e.g. after `followUp()`)                                                                            |
| `compaction_start`    | Pi began compacting context (manual or automatic)                                                                                         |
| `compaction_end`      | Context compaction finished; `result` has token counts, `aborted` if it was cancelled                                                     |
| `extension_error`     | A Pi extension threw an error                                                                                                             |

## Text-only with textOnly() [#text-only-with-textonly]

If you only care about the text output, use the `textOnly()` helper to filter the stream:

```ts
import { Alineo, textOnly } from "alineo";

for await (const chunk of textOnly(agent.prompt("Summarise this repo."))) {
  process.stdout.write(chunk);
}
```

`textOnly()` is a thin generator that passes through `text` events and drops everything else. It returns `AsyncIterable<string>` — the same interface as the old `PromptStream`.

## Observing tool calls [#observing-tool-calls]

Iterate the raw `AgentStream` to see every tool Pi invokes:

```ts
for await (const ev of agent.prompt("Run /workspace/script.py with python3.")) {
  switch (ev.type) {
    case "text":
      process.stdout.write(ev.text);
      break;

    case "tool_start":
      console.log(`\n[tool] ${ev.toolName}  args=${JSON.stringify(ev.args)}`);
      break;

    case "tool_update":
      // Partial output from a running tool (e.g. streaming bash stdout)
      process.stdout.write(`[partial: ${ev.toolName}]`);
      break;

    case "tool_end":
      console.log(`[tool] ${ev.toolName}  done  isError=${ev.isError}`);
      break;
  }
}
```

## Collecting tool events [#collecting-tool-events]

You can collect tool events alongside the text response:

```ts
const toolEvents: AgentEvent[] = [];
let text = "";

for await (const ev of agent.prompt("Write and run a hello world script.")) {
  if (ev.type === "text") {
    text += ev.text;
  } else {
    toolEvents.push(ev);
  }
}

const toolNames = toolEvents
  .filter((e) => e.type === "tool_start")
  .map((e) => (e as Extract<AgentEvent, { type: "tool_start" }>).toolName);

console.log("Tools used:", [...new Set(toolNames)].join(", "));
```

## bash() [#bash]

`agent.bash()` runs a shell command inside Pi's working context and returns the same `AgentStream` type — but unlike `prompt()`, it's not incrementally streamed. Pi returns bash output synchronously, so the full output arrives as a single `text` event once the command completes; no `tool_start`/`tool_update`/`tool_end` events are emitted for it:

```ts
for await (const chunk of textOnly(agent.bash("ls -la /workspace"))) {
  process.stdout.write(chunk);
}
```

## Lifecycle events [#lifecycle-events]

Every `prompt()` call fires a sequence of lifecycle events around the text and tool events. These are useful for measuring per-turn latency, building progress indicators, or capturing the full structured response.

```ts
for await (const ev of agent.prompt("Refactor this module.")) {
  switch (ev.type) {
    case "agent_start":
      console.time("agent");
      break;

    case "turn_start":
      console.log(`turn ${ev.turnIndex} started at ${new Date(ev.timestamp).toISOString()}`);
      break;

    case "text":
      process.stdout.write(ev.text);
      break;

    case "turn_end":
      // ev.message is the full assistant message; ev.toolResults holds tool results from this turn
      console.log(`\nturn ${ev.turnIndex} done — ${ev.toolResults.length} tool(s) used`);
      break;

    case "agent_end":
      // ev.messages contains every message generated across all turns
      console.timeEnd("agent");
      console.log(`total messages in run: ${ev.messages.length}`);
      break;
  }
}
```

The event order within a single turn is always:

```
agent_start
  turn_start (turnIndex: 0)
    message_start
      message_update  ← repeated for each streaming delta
      text            ← emitted alongside message_update for text deltas
    message_end
    tool_start / tool_update / tool_end  ← if Pi used tools
  turn_end
  turn_start (turnIndex: 1)  ← if Pi needed another turn
  ...
agent_end
```

## Observing thinking and tool-call deltas [#observing-thinking-and-tool-call-deltas]

`message_update` carries the raw Pi delta in its `delta` field. In addition to `text_delta` (which the bridge also surfaces as a `text` event), it can carry thinking and tool-call deltas from models that support extended thinking:

```ts
for await (const ev of agent.prompt("Solve this step by step.")) {
  if (ev.type === "message_update") {
    const delta = ev.delta as { type: string; delta?: string; thinking?: string };
    if (delta.type === "thinking_delta") {
      process.stdout.write(`[thinking] ${delta.thinking ?? ""}`);
    }
    // delta.type === "text_delta" is also emitted as a "text" event — no need to handle it twice
  }
  if (ev.type === "text") {
    process.stdout.write(ev.text);
  }
}
```

## Compaction events [#compaction-events]

Auto-compaction fires between turns when Pi's context fills past a threshold. The `compaction_start` and `compaction_end` events let you surface this to the user instead of silently pausing:

```ts
for await (const ev of agent.prompt("Analyse the entire repo.")) {
  switch (ev.type) {
    case "text":
      process.stdout.write(ev.text);
      break;

    case "compaction_start":
      console.log(`\n[compacting — ${ev.reason}]`);
      break;

    case "compaction_end":
      if (ev.result) {
        const saved = ev.result.tokensBefore - ev.result.estimatedTokensAfter;
        console.log(`[compaction done — freed ~${saved} tokens]`);
      }
      if (ev.aborted) {
        console.warn("[compaction aborted]");
      }
      break;
  }
}
```

`reason` is one of `"manual"` (triggered by `agent.compact()`), `"threshold"` (auto, approaching context limit), or `"overflow"` (auto, context was full).

## Queue events [#queue-events]

`queue_update` fires when the steering or follow-up queue changes — for example, immediately after `agent.followUp()` is called while a prompt is in flight:

```ts
const stream = agent.prompt("Write a long analysis...");

setTimeout(async () => {
  await agent.followUp("Now summarize that in one paragraph.");
}, 1000);

for await (const ev of stream) {
  if (ev.type === "text") process.stdout.write(ev.text);
  if (ev.type === "queue_update") {
    console.log(`\n[queued follow-ups: ${ev.followUp.length}]`);
  }
}
```


---

# Workspace setup
URL: /docs/agent/getting-started/workspace-setup

Declarative bash steps that run after Pi install and are baked into the snapshot.



The `setup` field in an agent spec lets you declare a list of named bash commands that run inside the sandbox after the Pi CLI is installed, before the snapshot is taken. Because the steps are baked into the snapshot, they only run once — subsequent `Alineo.load()` calls restore the prepared workspace in seconds.

## Defining setup steps [#defining-setup-steps]

```json title="agents/workspace-agent.json"
{
  "name": "workspace-agent",
  "cli": "pi",
  "model": "gemini-flash-latest",
  "packages": ["git", "python3"],
  "env": { "GEMINI_API_KEY": "${GEMINI_API_KEY}" },
  "resources": { "cpu": "1000m", "memory": "2Gi" },
  "setup": [
    { "name": "Clone repo", "run": "git clone https://github.com/owner/repo /workspace" },
    { "name": "Install deps", "run": "npm install", "cwd": "/workspace" },
    { "name": "Seed test data", "run": "node scripts/seed.js", "cwd": "/workspace" }
  ]
}
```

`env` values are normally plain strings, but any entry can instead be an object describing a credential to inject rather than a container environment variable — the value never becomes part of the sandbox's own `env` at all:

```json
"env": {
  "GEMINI_API_KEY": "${GEMINI_API_KEY}",
  "GITHUB_TOKEN": {
    "credential": "${GH_TOKEN}",
    "host": "api.github.com",
    "injection": { "type": "header", "name": "Authorization" }
  }
}
```

See [Credentials](/docs/core/concepts/credentials) for what injection means and how it behaves across `resume()`/`fork()`/spawned children.

Each step is a `SetupStep`:

| Field  | Type     | Required | Description                                                       |
| ------ | -------- | -------- | ----------------------------------------------------------------- |
| `name` | `string` | yes      | Human-readable label shown in logs and included in the setup hash |
| `run`  | `string` | yes      | Bash command to execute                                           |
| `cwd`  | `string` | no       | Working directory. The command runs as `cd <cwd> && <run>`        |

## Log output [#log-output]

Setup steps are logged with timing between the Pi install and the checkpoint:

```
[agent] installing Pi CLI...
[agent] Pi CLI ready    28000ms
[agent] setup: Clone repo...
[agent] setup done      4200ms (Clone repo)
[agent] setup: Install deps...
[agent] setup done      12300ms (Install deps)
[agent] setup: Seed test data...
[agent] setup done      980ms (Seed test data)
[agent] checkpointing...
[agent] checkpoint done 9800ms
```

On subsequent loads, none of these lines appear — the workspace is already in the snapshot.

## Cache invalidation [#cache-invalidation]

Any change to a step's `name`, `run`, or `cwd` is included in the [setup hash](/docs/agent/getting-started/snapshotting) and automatically invalidates the snapshot. The next `Alineo.load()` will run all steps again from scratch and create a new checkpoint.

## Self-contained steps [#self-contained-steps]

Setup steps that fetch from the internet (e.g. `git clone`, `npm install`) will re-fetch on every cache bust. For reproducible builds, pin versions explicitly:

```json
{ "name": "Install deps", "run": "npm ci", "cwd": "/workspace" }
```

For fully self-contained steps that don't require network access at all:

```json
[
  { "name": "Create workspace", "run": "mkdir -p /workspace" },
  { "name": "Write config", "run": "echo '{\"debug\":true}' > config.json", "cwd": "/workspace" }
]
```


---

# alineo
URL: /docs/alineo

alineo — a local CLI built on the alineo SDK package: start a local OpenSandbox server, manage agent spec files, and spawn/prompt/fork agent sessions directly from the shell.



<Cards>
  <Card href="/docs/alineo/getting-started" title="Getting Started" description="Install alineo, start a local OpenSandbox server, and fetch your first agent spec." />

  <Card href="/docs/alineo/commands" title="Commands" description="Full reference for every alineo command — SDK config, spec management, and agent session lifecycle." />

  <Card href="/docs/alineo/registry" title="Registry Format" description="Publish your own agent spec files so others can fetch them with a single command." />

  <Card href="/docs/alineo/using-sandboxes" title="Running an agent spec" description="Load an agent spec saved by alineo add with Alineo.load()." />
</Cards>


---

# Running an agent spec
URL: /docs/alineo/using-sandboxes

Load an agent spec saved by alineo add with Alineo.load() — alineo add itself never creates a sandbox.



`alineo add` only fetches and saves an `AgentSpec` JSON file — it never creates a sandbox, runs setup, or checkpoints anything. To actually run the agent programmatically, load the spec with [`alineo`](/docs/agent)'s `Alineo.load()`, as shown below. If you just want to run it from the shell without writing TypeScript, use [`alineo spawn`](/docs/alineo/commands/spawn) instead — it wraps the same `Alineo.load()` call.

## Setup [#setup]

```bash
bun add alineo @alineo-labs/sqlite
```

## Loading and prompting [#loading-and-prompting]

```ts
import { Alineo, textOnly } from "alineo";
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const adapter = new SQLiteAdapter("./.alineo/ledger.db");
const agent = await Alineo.load("agents/node-toolchain.json", { adapter });

for await (const chunk of textOnly(agent.prompt("What Node version is installed?"))) {
  process.stdout.write(chunk);
}

await agent.close();
```

`Alineo.load()` reads `alineo.config.json` itself — the same file `alineo init` writes — for the OpenSandbox server URL, `useServerProxy`, and default resource limits. You don't construct a `Sandbox` client or pass connection details by hand — the one thing you still construct yourself is the storage adapter (`opts.adapter`), since `alineo` doesn't depend on any specific one (`SQLiteAdapter` for local dev, `PostgresAdapter` for production).

## What happens on load [#what-happens-on-load]

* **First load for a spec**: spins up a `node:22` sandbox, installs the Pi CLI and any `setup` steps from the spec, then checkpoints the sandbox. This is the slow path.
* **Subsequent loads**: restore from that checkpoint, skipping the install entirely — much faster. The snapshot is keyed on a hash of `cli`, `cliVersion`, `packages`, and `setup`, so changing any of those forces a fresh install on the next load.
* Pass `{ rebuild: true }` to force a full reinstall regardless of the cache: `Alineo.load(specPath, { adapter, rebuild: true })`.

See [Snapshotting](/docs/agent/getting-started/snapshotting) for the full details.

## Accessing the underlying sandbox [#accessing-the-underlying-sandbox]

`agent.sandbox` is the live `SandboxHandle` from `@alineo-labs/core` — use it for anything not covered by the `Alineo` API, like reading files or exposing a port:

```ts
const agent = await Alineo.load("agents/web-service.json", { adapter });

await agent.sandbox.exec("node server.js &");
const { url, headers } = await agent.sandbox.proxy(3000);
const res = await fetch(`${url}/health`, { headers });
console.log(await res.text());

await agent.close();
```

## Multiple agents [#multiple-agents]

Each `Alineo.load()` call for the same spec restores an independent sandbox from the same checkpoint — running one doesn't affect another:

```ts
const [a1, a2] = await Promise.all([
  Alineo.load("agents/reviewer.json", { adapter }),
  Alineo.load("agents/reviewer.json", { adapter }),
]);

await Promise.all([
  (async () => {
    for await (const chunk of textOnly(a1.prompt("Review src/a.ts"))) process.stdout.write(chunk);
  })(),
  (async () => {
    for await (const chunk of textOnly(a2.prompt("Review src/b.ts"))) process.stdout.write(chunk);
  })(),
]);

await Promise.all([a1.close(), a2.close()]);
```

## Lifecycle [#lifecycle]

* `Alineo.load()` creates a sandbox (or resumes one). It costs resources until `agent.close()` is called.
* Always call `agent.close()` when done — use `try/finally` to ensure it runs on error.
* The checkpoint from the first load is preserved. You can `Alineo.load()` the same spec as many times as needed without repeating setup.

## See also [#see-also]

* [alineo overview](/docs/agent) — the full `Alineo` API: prompting, streaming, sessions, snapshotting
* [alineo core SDK — exec](/docs/core/building/exec)
* [alineo core SDK — file operations](/docs/core/building/file-ops)


---

# alineo add
URL: /docs/alineo/commands/add

Fetch an agent spec from a URL or local file and save it to your project's agents directory.



```bash
bunx alineo-cli add <url> [options]
```

## Arguments [#arguments]

| Argument | Description                                              |
| -------- | -------------------------------------------------------- |
| `url`    | URL or local file path to an `AgentSpec` JSON. Required. |

## Options [#options]

| Option          | Description                                                               |
| --------------- | ------------------------------------------------------------------------- |
| `--name <name>` | Override the saved file's name. Defaults to the `name` field in the spec. |

There is no `--server` flag — `add` doesn't talk to a server at all, it only reads and writes local files.

## What it does [#what-it-does]

1. Reads `alineo.config.json` for `agentsDir` (defaults to `./agents`). If no project-local `alineo.config.json` exists, falls back to a global config at `~/.config/alineo/config.json`, auto-creating it with defaults if that's missing too — `alineo init` is not actually required first.
2. Fetches the URL (or reads the local file) and validates it as an `AgentSpec`: `name` must be a string, `cli` must be `"pi"`. All other fields are unchecked.
3. Resolves any `registryDependencies` first — each dependency URL is fetched and saved the same way, recursively, depth-first, before the top-level spec is saved. Dependencies are **not** deduplicated across separate `add` invocations, or within recursive resolution of the same tree — a dependency listed twice is fetched and saved twice.
4. Writes the validated spec to `<agentsDir>/<name>.json`.

`add` never creates a sandbox, runs `setup`, or contacts the OpenSandbox server directly — it's a local file operation. The sandbox work happens later, the first time you call `Alineo.load()` on the saved spec (see [Running an agent spec](/docs/alineo/using-sandboxes)).

## Examples [#examples]

**From a URL:**

```bash
bunx alineo-cli add https://example.com/specs/node-toolchain.json
```

**From a local file:**

```bash
bunx alineo-cli add ./my-agent.json
```

**With a custom name:**

```bash
bunx alineo-cli add https://example.com/specs/node-toolchain.json --name ci-env
```

## Output [#output]

```
Agent spec saved: agents/node-toolchain.json
Load it with: Alineo.load("agents/node-toolchain.json") from alineo
```

## Notes [#notes]

* `alineo add` does not require `alineo init` to have been run first — it falls back to a global config, auto-creating one with defaults if none exists yet.
* If a spec's `registryDependencies` chain is large or has cycles, `add` will fetch and save every one of them without any cycle detection — be careful publishing self-referential or deeply nested dependency chains.
* Re-running `add` on the same URL overwrites the previously saved spec file with the latest content from the URL.


---

# alineo agents
URL: /docs/alineo/commands/agents

List running agent sessions, cross-checked against the live OpenSandbox control plane.



```bash
bunx alineo-cli agents [--json]
```

## Flags [#flags]

| Flag     | Description                                                  |
| -------- | ------------------------------------------------------------ |
| `--json` | Print the tracked sessions array as JSON instead of a table. |

## What it does [#what-it-does]

1. Reads the ledger's "Running" entries.
2. Cross-checks each one against a **live** query to the OpenSandbox control plane, not the ledger alone. The ledger's "Running" status only updates on a graceful `agent.close()`/`sb.close()` — a sandbox that crashed, was killed out-of-band, or expired via OpenSandbox's own TTL stays "Running" in the ledger forever with nothing to correct it. Entries that no longer exist on the control plane are silently dropped from the table.
3. Also lists sandboxes actually running on the same OpenSandbox server that **aren't** in this CLI invocation's own ledger at all — labeled "Untracked". This is the normal case for a child sandbox created via `alineo fork` from inside another sandbox, since that fork uses the child sandbox's *own* internal ledger, not the host's.

## Example output [#example-output]

```
NAME                  SANDBOX ID                              STARTED     EXECS
--------------------------------------------------------------------------------
rlm-master            4af65c3b-24a2-4fd1-999d-918faa9b97fd    2m ago      8

Untracked (not created by alineo, e.g. agent-spawned children):
  6a69afcb-7a00-437a-9819-e032022cc736
```

| Column       | Description                                                                                                                                                                                                                                              |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NAME`       | The session name from the ledger. Truncated to 19 characters.                                                                                                                                                                                            |
| `SANDBOX ID` | The full sandbox ID — pass this to `alineo prompt`, `alineo kill`, `alineo logs`.                                                                                                                                                                        |
| `STARTED`    | Relative time since the sandbox was created.                                                                                                                                                                                                             |
| `EXECS`      | Number of top-level execs logged against this sandbox in the ledger — **not** the number of individual bash commands a Pi session ran internally, since those happen inside the single long-running bridge process exec, not as separate ledger entries. |

With `--json`, only the tracked (named) sessions are printed — untracked sandbox IDs are omitted from the JSON output.

## Notes [#notes]

* If nothing is running, prints `(no running alineo-tracked sessions — run 'alineo spawn <spec>' to start one)` instead of an empty table.
* The live control-plane check means `alineo agents` makes a real network call on every invocation — it's not a pure local-file read like `alineo list`.


---

# alineo fork
URL: /docs/alineo/commands/fork

Fork a running session's own live sandbox into a brand-new independent child agent.



```bash
bunx alineo-cli fork <name> <child-spec> [--prompt <msg>] [--depth <n>] [--max <n>] [--json]
```

## Arguments [#arguments]

| Argument     | Description                                                                                                 |
| ------------ | ----------------------------------------------------------------------------------------------------------- |
| `name`       | The **caller's own** running session — used only to resolve its sandbox ID, not the child's name. Required. |
| `child-spec` | Path to the child's agent spec JSON file. Required.                                                         |

## Flags [#flags]

| Flag             | Description                                                                                                 |
| ---------------- | ----------------------------------------------------------------------------------------------------------- |
| `--prompt <msg>` | Send one prompt to the child immediately after it's ready, and print the reply before exiting.              |
| `--depth <n>`    | Override the caller's own `spawnDepth` budget instead of reading `ALINEO_SPAWN_DEPTH` from the environment. |
| `--max <n>`      | Override the caller's own `maxAgents` budget instead of reading `ALINEO_MAX_AGENTS` from the environment.   |
| `--json`         | Print `{ name, sandboxId, reply }` as JSON instead of the default text output.                              |

## What it does [#what-it-does]

`alineo fork` is meant to be run **by a session's own Pi bash tool**, not from a fresh host shell — `name` labels the *caller's own* running session, resolved to a sandbox ID two ways:

1. If `ALINEO_SANDBOX_ID` is set in this process's own environment (true when running as a descendant of a Pi bridge process inside a alineo-managed sandbox — which is exactly the case when a Pi session's bash tool runs `alineo fork`), that's used directly.
2. Otherwise, `name` is looked up against the ledger's running sessions.

The caller's sandbox is then resolved via [`Alineo.attach()`](/docs/agent/api-reference/agent#agentattach) — **not** `Alineo.resume()`, which would kill the very bridge process making this call — and [`agent.spawn(childSpec, { spawnDepth, maxAgents })`](/docs/agent/api-reference/agent#agentspawn) forks its live sandbox into the child.

Unlike `alineo spawn` (always starts from a spec's own snapshot), the child sees exactly what the calling sandbox sees right now — filesystem, installed packages, uncommitted work — with no install/setup steps.

## Example [#example]

```bash
# Run from inside a Pi session's own bash tool, with ALINEO_SANDBOX_ID already set:
bunx alineo-cli fork my-session ./agents/worker.json --prompt "Handle the auth module"
```

```
[alineo] forked: fork-my-session-a1b2c3  sandbox: 6a69afcb-7a00-437a-9819-e032022cc736
```

## Recursive spawning budgets [#recursive-spawning-budgets]

A spec's `spawnDepth` is a nesting-depth budget, force-decremented (`current - 1`) into the child's env on every fork. `0` means no budget left — `alineo fork` refuses immediately with an error. `undefined` means forking was never enabled for that spec at all.

`maxAgents` is a separate, optional ceiling on total descendants for **one lineage**, independent of nesting depth. Unset means uncapped. It is **not** coordinated across sibling branches spawned in parallel — it's a per-lineage counter, checked and decremented independently down each branch, not a global budget shared across an entire tree.

```json
{
  "name": "orchestrator",
  "cli": "pi",
  "spawnDepth": 2,
  "maxAgents": 10
}
```

## Notes [#notes]

* The forked child's environment is resolved fresh from **its own** spec — nothing is inherited from the caller except the force-computed spawn-depth counter. Every env var the caller's own spec declares is explicitly unset in the shell command that starts the child's bridge, since the forked container's OS-level env otherwise still carries whatever was baked in at snapshot time.
* No install or setup steps run for the child — it inherits whatever is already installed on the caller's sandbox. If the child needs packages the caller doesn't have, add them to a setup step on the spec the **caller** was loaded from, not the child's.
* See the [Pi extension](/docs/alineo/commands#pi-extension) section on the commands index for how a Pi session gets `alineo fork` syntax injected into its own guidance automatically.


---

# Commands
URL: /docs/alineo/commands

All alineo commands and their options.



## SDK — OpenSandbox config and the local spec cache [#sdk--opensandbox-config-and-the-local-spec-cache]

<Cards>
  <Card href="/docs/alineo/commands/init" title="init" description="Start a local OpenSandbox server via Docker." />

  <Card href="/docs/alineo/commands/add" title="add" description="Fetch an agent spec from a URL or file and save it locally." />

  <Card href="/docs/alineo/commands/list" title="list" description="List the agent specs saved in this project." />

  <Card href="/docs/alineo/commands/remove" title="remove" description="Delete a saved agent spec file." />

  <Card href="/docs/alineo/commands/telemetry" title="telemetry" description="Show, enable, or disable anonymous CLI usage telemetry." />
</Cards>

## Alineo — session lifecycle [#alineo--session-lifecycle]

These wrap `alineo`'s `Alineo.load()`/`Alineo.resume()`/`Alineo.attach()`/`Alineo.spawn()` directly. Sessions are usually addressed by **sandbox ID** — `prompt` and `kill` require it (see [alineo prompt](/docs/alineo/commands/prompt#why-sandbox-id-not-name) for why). `fork` and `logs` are addressed by session **name** instead — see their own pages for why. `spawn` starts a fresh session and takes a spec path, not an identifier.

<Cards>
  <Card href="/docs/alineo/commands/spawn" title="spawn" description="Start a fresh, independent agent sandbox from a spec's own snapshot." />

  <Card href="/docs/alineo/commands/prompt" title="prompt" description="Send one prompt to a running sandbox and print the reply." />

  <Card href="/docs/alineo/commands/fork" title="fork" description="Fork a running session's own live sandbox into a new child agent." />

  <Card href="/docs/alineo/commands/agents" title="agents" description="List running agent sessions, cross-checked against the live control plane." />

  <Card href="/docs/alineo/commands/kill" title="kill" description="Stop a sandbox by ID." />

  <Card href="/docs/alineo/commands/logs" title="logs" description="Print ledger events for a session." />
</Cards>

## Recursive spawning (`alineo fork`) [#recursive-spawning-alineo-fork]

A spec's `spawnDepth` is a nesting-depth budget — required for `alineo fork` to be allowed from inside a session at all. Each fork force-decrements it (`current - 1`) into the child's env; `0` means no budget left, `undefined` means forking was never enabled for that spec.

`maxAgents` is a separate, optional ceiling on total descendants for one lineage, independent of nesting depth. Unset means uncapped. **Not** coordinated across sibling branches spawned in parallel — it's a per-lineage counter, not a global one.

```json
{
  "name": "orchestrator",
  "cli": "pi",
  "spawnDepth": 2,
  "maxAgents": 10
}
```

See [alineo fork](/docs/alineo/commands/fork) for the full mechanics.

## Pi extension [#pi-extension]

`pi install npm:alineo-cli` installs the alineo extension into [Pi](https://pi.ai) at user scope. Once installed, any Pi session:

* Bootstraps `alineo` automatically on first use (installs it, runs `alineo init`) — no manual setup.
* Gets `alineo spawn`/`alineo fork` CLI syntax injected into its own guidance, dynamically chosen based on whether the current session is itself running inside a alineo-managed sandbox.

The extension source lives at `pi-extension/alineo.ts` in the `alineo` npm package.


---

# alineo init
URL: /docs/alineo/commands/init

Start a local OpenSandbox server in Docker and configure the current project.



```bash
bunx alineo-cli init
```

## What it does [#what-it-does]

1. Checks that Docker is running.
2. If an OpenSandbox container named `alineo-opensandbox` is already running, exits immediately — nothing changes.
3. If the container exists but is stopped, restarts it.
4. Otherwise, pulls `opensandbox/server:latest` and starts it, mounting the Docker socket so the server can manage sandbox containers.
5. Waits up to 60 seconds for the server to report healthy.
6. Writes `alineo.config.json` in the current directory (project root) if it doesn't already exist.

The server it starts is configured with credential injection available out of the box — a sandbox that opts in with `networkPolicy`/`credentialProxy` can use `sb.credentials.*` with no extra setup on the server side. See [Credentials](/docs/core/concepts/credentials). Sandboxes that don't ask for it are unaffected.

## Output files [#output-files]

### `alineo.config.json` [#alineoconfigjson]

Written once, in the project root (not under `.alineo/`). Contains the connection details and defaults used by both the `alineo` CLI and the `alineo` SDK package.

```json
{
  "serverUrl": "http://127.0.0.1:8080",
  "useServerProxy": true,
  "apiKey": "",
  "adapterPath": "./.alineo/ledger.db",
  "agentsDir": "./agents",
  "defaults": {
    "resources": { "cpu": "1000m", "memory": "1Gi" }
  }
}
```

| Field                | Description                                                                                                 |
| -------------------- | ----------------------------------------------------------------------------------------------------------- |
| `serverUrl`          | The OpenSandbox server URL.                                                                                 |
| `useServerProxy`     | Always `true` when started via `alineo init`. The `alineo` SDK package reads this for its `Sandbox` client. |
| `apiKey`             | Empty for local dev. Set this if your server requires authentication.                                       |
| `adapterPath`        | Path to the SQLite ledger database for this project.                                                        |
| `agentsDir`          | Directory `alineo add`/`list`/`remove` read and write agent spec files in.                                  |
| `defaults.resources` | CPU/memory used for an agent's sandbox when its spec omits `resources`.                                     |

## Idempotency [#idempotency]

`alineo init` is safe to run multiple times. If the server is already running it prints the URL and exits. If `alineo.config.json` already exists it is not overwritten.

## Docker requirements [#docker-requirements]

* Docker Engine must be running before calling `alineo init`.
* Port `8080` must be free.
* The Docker socket `/var/run/docker.sock` must be accessible (standard on Linux and macOS with Docker Desktop).

## Stopping the server [#stopping-the-server]

`alineo init` starts the server but does not provide a stop command. To stop it:

```bash
docker stop alineo-opensandbox
```

To remove the container entirely:

```bash
docker rm alineo-opensandbox
```

The next `alineo init` will start a fresh container.


---

# alineo kill
URL: /docs/alineo/commands/kill

Stop a sandbox by ID.



```bash
bunx alineo-cli kill <sandbox-id>
```

## Arguments [#arguments]

| Argument     | Description                                                                                       |
| ------------ | ------------------------------------------------------------------------------------------------- |
| `sandbox-id` | The sandbox ID, as shown by `alineo agents` or printed by `alineo spawn`/`alineo fork`. Required. |

## What it does [#what-it-does]

Connects to the sandbox via a live control-plane check (the same authoritative liveness check `alineo prompt` uses — see [Why sandbox ID, not name](/docs/alineo/commands/prompt#why-sandbox-id-not-name)) and closes it, deleting the container and releasing its resources.

## Example [#example]

```bash
bunx alineo-cli kill 4af65c3b-24a2-4fd1-999d-918faa9b97fd
```

```
Killed sandbox 4af65c3b-24a2-4fd1-999d-918faa9b97fd
```

## Notes [#notes]

* `kill` only stops the sandbox it's given — it does not recursively stop any children that sandbox may have forked via `alineo fork`. Kill each one individually, or check `alineo agents` for the full list including untracked (agent-spawned) sandboxes.
* If the sandbox is already stopped or never existed, the underlying live check fails and `kill` errors out rather than silently succeeding.


---

# alineo list
URL: /docs/alineo/commands/list

List the agent specs saved in the current project's agents directory.



```bash
bunx alineo-cli list
```

Reads `alineo.config.json` for `agentsDir` (defaults to `./agents`), lists every `.json` file there, and prints a table. It does not query the OpenSandbox server or check whether any sandbox exists for a spec — it only reads local files.

## Example output [#example-output]

```
NAME                  CLI       DESCRIPTION
node-toolchain        pi        A Node.js sandbox with TypeScript tooling.
python-data-sci        pi        A Python sandbox for data science work.
```

| Column        | Description                                                                                           |
| ------------- | ----------------------------------------------------------------------------------------------------- |
| `NAME`        | The spec's `name` field, or the filename (without `.json`) as a fallback. Truncated to 19 characters. |
| `CLI`         | The spec's `cli` field (currently always `"pi"`). Truncated to 7 characters.                          |
| `DESCRIPTION` | The spec's `description` field, falling back to `title`, or blank if neither is set.                  |

A spec file that fails to parse as JSON is still listed, with `(unreadable)` in place of its columns, instead of being silently skipped.

## Notes [#notes]

* `list` only reflects what's in `agentsDir` on disk — a spec appearing here does not mean it has ever been `Alineo.load()`ed or has a sandbox snapshot yet.
* If `agentsDir` doesn't exist, `list` prints `No agents dir found at '<dir>'. Run 'alineo add <url>' to add an agent spec.` and exits cleanly.
* If `agentsDir` exists but is empty, `list` prints `No agent specs found. Run 'alineo add <url>' to add one.` and exits cleanly.


---

# alineo logs
URL: /docs/alineo/commands/logs

Print ledger events for a session.



```bash
bunx alineo-cli logs <name> [--json]
```

## Arguments [#arguments]

| Argument | Description                                              |
| -------- | -------------------------------------------------------- |
| `name`   | The session name, as shown by `alineo agents`. Required. |

## Flags [#flags]

| Flag     | Description                                |
| -------- | ------------------------------------------ |
| `--json` | Print the full ledger entry array as JSON. |

## What it does [#what-it-does]

Looks up the most recent sandbox for `name` in the ledger and prints every ledger event recorded against it — `sandbox_created`, `exec_start`/`exec_event`/`exec_complete`, `checkpoint_created`, `sandbox_closed`, and so on.

Note this reads by **name**, unlike `alineo prompt`/`alineo kill` which read by sandbox ID — `logs` is a read-only audit trail, not a liveness-sensitive operation, so the staleness/non-uniqueness concerns that motivate ID-based addressing elsewhere don't apply the same way. If a name has multiple sandboxes in the ledger, the newest one is used.

## Example [#example]

```bash
bunx alineo-cli logs my-session
```

```
14 events for 'my-session' (4af65c3b-24a2-4fd1-999d-918faa9b97fd):

2026-07-12T00:59:31.123Z  sandbox_created
2026-07-12T00:59:32.456Z  exec_start
  {"cmd":"apt-get update -qq && apt-get install -y --no-install-recommends git","seq":1}
2026-07-12T00:59:41.789Z  exec_complete
  {"exitCode":0,"seq":1}
```

Long payloads are truncated to 200 characters in text mode. Use `--json` for the full, untruncated payloads.

## Notes [#notes]

* Only shows what's in **this CLI invocation's own** ledger — a child sandbox forked via `alineo fork` from inside another sandbox writes to that sandbox's own internal ledger, not this one. Use `alineo agents` from inside the child's own project context to inspect it, or connect to it directly.
* Errors with `No session named '<name>' found in the ledger.` if nothing matches.


---

# alineo prompt
URL: /docs/alineo/commands/prompt

Send one prompt to a running agent sandbox and print the reply.



```bash
bunx alineo-cli prompt <sandbox-id> <message> [--spec <path>] [--json]
```

## Arguments [#arguments]

| Argument     | Description                                                       |
| ------------ | ----------------------------------------------------------------- |
| `sandbox-id` | The sandbox ID printed by `alineo spawn`/`alineo fork`. Required. |
| `message`    | The prompt text to send. Required.                                |

## Flags [#flags]

| Flag            | Description                                                                                                |
| --------------- | ---------------------------------------------------------------------------------------------------------- |
| `--spec <path>` | Skip the ledger lookup for the spec file — see [Why sandbox ID, not name](#why-sandbox-id-not-name) below. |
| `--json`        | Print `{ name, sandboxId, reply }` as JSON instead of the raw reply text.                                  |

## What it does [#what-it-does]

Calls [`Alineo.resume(sandboxId, { adapter, specPath })`](/docs/agent/api-reference/agent#agentresume), sends `message`, and prints the full reply once Pi finishes responding. `Alineo.resume()` restarts the sandbox's bridge process — Pi and the workspace are untouched, but any interactive stream a *different* process had open against that sandbox will be interrupted.

## Example [#example]

```bash
bunx alineo-cli prompt 4af65c3b-24a2-4fd1-999d-918faa9b97fd "What's in /tmp?"
```

## Why sandbox ID, not name [#why-sandbox-id-not-name]

Every other `alineo` command that looks up a session takes a sandbox ID, not a name. Names aren't unique — running `alineo spawn` twice on the same spec produces two sandboxes with the same name — and a name-based ledger lookup can hand back a sandbox that already died ungracefully (crashed before its `close()` ran, expired via OpenSandbox's own TTL) since nothing tells the ledger it stopped. `Alineo.resume()`'s own `connect()` call is the actual authoritative liveness check; addressing by sandbox ID means that's the *only* check, not a second opinion layered on an already-stale one.

`--spec <path>` skips `Alineo.resume()`'s own ledger lookup for the spec file entirely — needed when the sandbox's own `sandbox_created` event lives in a different ledger than this CLI invocation's own. This happens when prompting a child sandbox created via `alineo fork` from inside another sandbox: the child's creation event is recorded in *that sandbox's own* internal ledger, not the one this CLI invocation reads from.

## Notes [#notes]

* `prompt` waits for the entire reply before printing anything — there's no streaming/incremental output on the CLI. For streaming, use `alineo`'s `agent.prompt()` directly.
* If the sandbox no longer exists (closed, expired, or never existed), `Alineo.resume()`'s `connect()` call fails and the command errors out rather than hanging.


---

# alineo remove
URL: /docs/alineo/commands/remove

Delete a saved agent spec file from the current project.



```bash
bunx alineo-cli remove <name>
```

## Arguments [#arguments]

| Argument | Description                                        |
| -------- | -------------------------------------------------- |
| `name`   | The spec name as shown by `alineo list`. Required. |

## What it does [#what-it-does]

1. Looks for `<agentsDir>/<name>.json`. Throws `No agent spec named '<name>' in '<agentsDir>'. Run 'alineo list' to see available specs.` if it doesn't exist.
2. Deletes the file.

No network calls are made and no sandbox is touched — `remove` is a plain local file delete.

## Example [#example]

```bash
bunx alineo-cli remove node-toolchain
# Removed agent spec 'node-toolchain'
```

## Notes [#notes]

* `remove` only deletes the spec JSON file. It does not delete any sandbox snapshot created by a prior `Alineo.load()` — that's tracked separately, in `agent-snapshots.json` next to your ledger — nor does it touch the ledger itself.
* To remove a spec that was saved with `--name` on `add`, use that custom name, not the `name` field from inside the spec file.


---

# alineo spawn
URL: /docs/alineo/commands/spawn

Start a brand-new, independent agent sandbox from a spec's own snapshot.



```bash
bunx alineo-cli spawn <spec> [--prompt <msg>] [--rebuild] [--depth <n>] [--max <n>] [--json]
```

## Arguments [#arguments]

| Argument | Description                                |
| -------- | ------------------------------------------ |
| `spec`   | Path to an agent spec JSON file. Required. |

## Flags [#flags]

| Flag             | Description                                                                                                                         |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `--prompt <msg>` | Send one prompt immediately after the sandbox is ready, and print the reply before exiting.                                         |
| `--rebuild`      | Force a full reinstall instead of restoring from the cached snapshot. See [Snapshotting](/docs/agent/getting-started/snapshotting). |
| `--depth <n>`    | Override the spec's own `spawnDepth` field.                                                                                         |
| `--max <n>`      | Override the spec's own `maxAgents` field.                                                                                          |
| `--json`         | Print `{ name, sandboxId, reply }` as JSON instead of the default text output.                                                      |

## What it does [#what-it-does]

1. Reads `alineo.config.json` and calls [`Alineo.load(spec, { adapter, rebuild, spawnDepth, maxAgents })`](/docs/agent/api-reference/agent#agentload) — see that page for the full snapshot/install behavior. This always starts from the spec's own snapshot (or builds one on first use); it never inspects the calling process's own sandbox state.
2. If `--prompt` is given, sends that message and waits for the full reply before printing anything and exiting.
3. Without `--prompt`, prints the sandbox name and ID and exits immediately — the sandbox keeps running.

## Example [#example]

```bash
bunx alineo-cli spawn ./agents/my-agent.json
```

```
[alineo] session: my-agent  sandbox: 4af65c3b-24a2-4fd1-999d-918faa9b97fd
```

```bash
bunx alineo-cli spawn ./agents/my-agent.json --prompt "Explain this repo" --json
```

```json
{ "name": "my-agent", "sandboxId": "4af65c3b-...", "reply": "..." }
```

## spawn vs. fork [#spawn-vs-fork]

`alineo spawn` is the entry point for a **fresh** session — e.g. a host-level Pi session starting the master of a recursive-agent run. [`alineo fork`](/docs/alineo/commands/fork) instead branches an **already-running** session's own live sandbox state. Use `spawn` when nothing exists yet; use `fork` when you're running *inside* a session and want to delegate part of the current work to a child.

## Notes [#notes]

* The sandbox ID printed here is what `alineo prompt`, `alineo kill`, and `alineo logs` need — it is not saved anywhere else automatically.
* `--depth`/`--max` only matter if the spec itself (via `agent.spawn()`) or a later `alineo fork` call from inside that sandbox will go on to fork children.


---

# alineo telemetry
URL: /docs/alineo/commands/telemetry

Show, enable, or disable anonymous CLI usage telemetry.



```bash
bunx alineo-cli telemetry status|enable|disable
```

## Arguments [#arguments]

| Argument                      | Description                                                         |
| ----------------------------- | ------------------------------------------------------------------- |
| `status` (default if omitted) | Print whether telemetry is enabled and this machine's anonymous ID. |
| `enable`                      | Turn telemetry on.                                                  |
| `disable`                     | Turn telemetry off.                                                 |

## What it does [#what-it-does]

`alineo` can send small, anonymous usage events — which subcommand ran, a per-command allowlist of boolean flag presence, success/failure, and timing — to help prioritize development. `alineo telemetry` reads and writes the local config file that controls this (`~/.config/alineo/telemetry.json`), created on first use with a random `anonymousId`.

**Default-on**, sent to `https://telemetry.alineo.tech`. See [Opting out](#opting-out) below.

## What's collected [#whats-collected]

Never anything beyond this:

| Field                                                      | Example                                                                            |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `command`                                                  | `"spawn"`                                                                          |
| `flags`                                                    | `{ "json": true, "prompt": false }` — presence only, never values                  |
| `specProvider`                                             | `"nvidia"` — `spawn`/`fork` only, read from the target spec's own `provider` field |
| `outcome`                                                  | `"success"` or `"error"`                                                           |
| `errorClass`                                               | `"CommandError"` — the error's constructor name only, never its message            |
| `durationMs`                                               | `842`                                                                              |
| `cliVersion`, `osPlatform`, `osArch`, `bunVersion`, `isCI` | environment metadata                                                               |
| `anonymousId`                                              | a random UUID generated once per machine                                           |

**Never collected**: raw `argv`, flag values, file paths, spec contents, prompts, sandbox output, or anything else that could identify you or your code.

## Example [#example]

```bash
bunx alineo-cli telemetry status
```

```
[alineo] telemetry: enabled
  anonymous id: 3f2e9c1a-8b7d-4e6f-a1c2-9d8e7f6a5b4c
```

```bash
bunx alineo-cli telemetry disable
# [alineo] telemetry disabled
```

## Opting out [#opting-out]

Telemetry is on by default. Turn it off any time with:

```bash
bunx alineo-cli telemetry disable
```

or by setting either env var, which take priority over the persisted config and require no config file read at all:

```bash
ALINEO_TELEMETRY_DISABLED=1 alineo spawn ./agents/my-agent.json
# or the cross-tool convention:
DO_NOT_TRACK=1 alineo spawn ./agents/my-agent.json
```

## Notes [#notes]

* A one-time notice is printed to stderr the first time an event would actually send, naming both opt-out mechanisms — never mixed into `--json` output.
* Sending is bounded: a 500ms timeout races the request, and a failed or slow send never delays or fails the real command it's attached to.
* Scoped to the `alineo` CLI only — `alineo`/`alineo`/`@alineo-labs/workflow` used as libraries in your own code are never instrumented.


---

# What is alineo?
URL: /docs/alineo/getting-started

alineo starts a local OpenSandbox server, manages alineo spec files, and drives agent sessions directly from the command line — spawn, prompt, fork, and orchestrate without writing TypeScript.



The `alineo` CLI is built on the [`alineo` SDK package](/docs/agent). It covers two layers:

* **SDK config and spec management** — `init` starts a local OpenSandbox server; `add`/`list`/`remove` manage `AgentSpec` JSON files on disk. Neither of these touches a sandbox.
* **Alineo session lifecycle** — `spawn`/`prompt`/`fork`/`agents`/`kill`/`logs` wrap `Alineo.load()`/`resume()`/`attach()`/`spawn()` directly, so you can start, talk to, and orchestrate agent sessions entirely from the shell — including recursive forking, where a running session forks its own live sandbox into child agents (see `alineo fork` in the [Commands reference](/docs/alineo/commands)).

```bash
bunx alineo-cli init
bunx alineo-cli add https://example.com/my-agent.json
bunx alineo-cli spawn agents/my-agent.json --prompt "Explain this repo"
```

The first command starts a local OpenSandbox server via Docker and writes `alineo.config.json` to your project. The second fetches an agent spec from a URL (or local file) and saves it under `./agents/<name>.json`. The third starts a real agent sandbox from that spec, sends one prompt, and prints the reply — no TypeScript required.

## When to use alineo [#when-to-use-alineo]

* You want a local OpenSandbox server running without manually configuring or running `uvx opensandbox-server`.
* You want to fetch a shared agent spec (e.g. a pre-configured Pi coding-agent setup) from a URL instead of writing the JSON by hand.
* You want to start, prompt, or orchestrate agent sessions directly from the shell or from inside a Pi session (see the [Pi extension](/docs/alineo/commands#pi-extension)), without writing a TypeScript entry point.

## How it relates to the `alineo` SDK package [#how-it-relates-to-the-alineo-sdk-package]

`alineo spawn`/`prompt`/`fork`/`agents`/`kill`/`logs` are thin CLI wrappers around the `alineo` SDK package's own `Alineo` class — same behavior, same `alineo.config.json`, same ledger. Reach for the `alineo` SDK package directly instead of the CLI when you need to drive an agent programmatically (streaming responses in your own app, custom tool-event handling, etc.):

```ts
import { Alineo } from "alineo";
import { SQLiteAdapter } from "@alineo-labs/sqlite";

const adapter = new SQLiteAdapter("./.alineo/ledger.db");
const agent = await Alineo.load("./agents/my-agent.json", { adapter });
```

`Alineo.load()` reads `alineo.config.json` itself (the same file `alineo init` writes) to know where the OpenSandbox server is — you don't pass connection details manually.

## Next steps [#next-steps]

<Cards>
  <Card href="/docs/alineo/getting-started/quickstart" title="Quickstart" description="Install alineo, start OpenSandbox, and fetch your first agent spec." />

  <Card href="/docs/alineo/commands" title="Commands" description="Full reference for every alineo command." />
</Cards>


---

# Quickstart
URL: /docs/alineo/getting-started/quickstart

Start a local OpenSandbox server and fetch your first agent spec in under five minutes.



## Prerequisites [#prerequisites]

* [Docker](https://docs.docker.com/get-docker/) installed and running.
* [Bun](https://bun.sh) installed.

<Steps>
  <Step>
    ### Start OpenSandbox locally [#start-opensandbox-locally]

    Run `alineo init` once per project. It starts an OpenSandbox server in Docker and writes a `alineo.config.json` to your project root.

    ```bash
    bunx alineo-cli init
    ```

    ```
    Checking Docker...
    Starting OpenSandbox in Docker...
    Waiting for OpenSandbox to be ready...
    OpenSandbox running at http://127.0.0.1:8080 — ready.
    ```

    If the server is already running from a previous `init`, `alineo init` prints the URL and exits immediately without starting a second container.
  </Step>

  <Step>
    ### Fetch an agent spec [#fetch-an-agent-spec]

    Point `alineo add` at any URL (or local file path) that returns a valid `AgentSpec` JSON:

    ```bash
    bunx alineo-cli add https://example.com/specs/code-reviewer.json
    ```

    ```
    Agent spec saved: agents/code-reviewer.json
    Load it with: Alineo.load("agents/code-reviewer.json") from alineo
    ```

    `alineo add` fetches the spec, validates it (`name` and `cli: "pi"` are required), resolves any `registryDependencies` first (fetched recursively, depth-first), and writes it to `<agentsDir>/<name>.json` — `agentsDir` defaults to `./agents`. It does **not** create a sandbox, run setup, or checkpoint anything — that happens later, the first time you `Alineo.load()` the spec.
  </Step>

  <Step>
    ### List your saved specs [#list-your-saved-specs]

    ```bash
    bunx alineo-cli list
    ```

    ```
    NAME                  CLI       DESCRIPTION
    code-reviewer         pi        A Node.js sandbox pre-loaded with TypeScript tooling.
    ```
  </Step>

  <Step>
    ### Run the agent [#run-the-agent]

    `Alineo.load()` (from `alineo`) does the actual sandbox work: on first load it spins up a `node:22` sandbox, installs the Pi CLI and any `setup` steps, then checkpoints it. Subsequent loads restore from that snapshot, skipping the install.

    ```ts
    import { Alineo, textOnly } from "alineo";
    import { SQLiteAdapter } from "@alineo-labs/sqlite";

    const adapter = new SQLiteAdapter("./.alineo/ledger.db");
    const agent = await Alineo.load("agents/code-reviewer.json", { adapter });

    for await (const chunk of textOnly(agent.prompt("Review this repo for obvious bugs."))) {
      process.stdout.write(chunk);
    }

    await agent.close();
    ```

    `Alineo.load()` reads `alineo.config.json` itself for the OpenSandbox server URL and other defaults — the same file `alineo init` wrote in step 1. You don't need to construct a `Sandbox` client or pass connection details by hand — the one thing you still construct yourself is the storage adapter (`opts.adapter`), since `alineo` doesn't depend on any specific one.
  </Step>

  <Step>
    ### Remove a spec [#remove-a-spec]

    ```bash
    bunx alineo-cli remove code-reviewer
    ```

    ```
    Removed agent spec 'code-reviewer'
    ```

    This deletes `agents/code-reviewer.json`. It's purely a local file operation — no sandbox or snapshot is touched. If you'd previously run `Alineo.load()` on that spec, its sandbox snapshot (tracked separately, in `agent-snapshots.json` next to your ledger) is unaffected.
  </Step>
</Steps>

## Next steps [#next-steps]

* [Commands reference](/docs/alineo/commands) — all flags for each command
* [Registry format](/docs/alineo/registry) — publish your own agent spec
* [Running an agent spec](/docs/alineo/using-sandboxes) — full details on `Alineo.load()`
* [alineo overview](/docs/agent) — the full Alineo API (prompting, streaming, sessions, snapshotting)


---

# Registry
URL: /docs/alineo/registry

Publish an AgentSpec JSON file so others can fetch it with a single command.



An alineo "registry item" is just a URL that returns an `AgentSpec` JSON object (the same spec type used by [`alineo`](/docs/agent)). Any URL works — a GitHub raw file, a Gist, your own server, or the small curated set of examples alineo hosts at [registry.alineo.tech](https://registry.alineo.tech) — there's no requirement to publish through a central service.

<Cards>
  <Card href="/docs/alineo/registry/schema" title="AgentSpec schema" description="All fields, types, and constraints for a valid agent spec." />
</Cards>

## Publishing a spec [#publishing-a-spec]

An agent spec is a static JSON file. Hosting it is as simple as pushing it to a public GitHub repository and sharing the raw URL:

```
https://raw.githubusercontent.com/your-org/your-repo/main/agents/node-toolchain.json
```

Anyone can then fetch it into their own project with:

```bash
bunx alineo-cli add https://raw.githubusercontent.com/your-org/your-repo/main/agents/node-toolchain.json
```

This only saves the spec locally — it doesn't run anything. See [Running an agent spec](/docs/alineo/using-sandboxes) for how to actually load and run it with `Alineo.load()`.

## Minimal example [#minimal-example]

Every spec runs inside a `node:22` sandbox (there's no `image` field — the base image is fixed) and requires `cli: "pi"`:

```json
{
  "name": "node-toolchain",
  "cli": "pi",
  "setup": [{ "name": "install tools", "run": "npm install -g typescript eslint prettier" }]
}
```

## Composition with `registryDependencies` [#composition-with-registrydependencies]

A spec can declare dependencies on other specs by URL. `alineo add` fetches and saves them first, depth-first, before saving the top-level spec — it does not merge or run them, each dependency just ends up as its own separate spec file in `agentsDir`:

```json
{
  "name": "ts-reviewer",
  "cli": "pi",
  "registryDependencies": ["https://example.com/agents/node-toolchain.json"],
  "setup": [{ "name": "install linter", "run": "npm install -g @typescript-eslint/parser" }]
}
```

There's no cycle detection — avoid dependency chains that reference each other.


---

# AgentSpec schema
URL: /docs/alineo/registry/schema

Complete field reference for the AgentSpec JSON fetched by alineo add and run by alineo's Alineo.load().



An agent spec is a JSON object — the same `AgentSpec` type `alineo` exports. Only `name` and `cli` are required; `alineo add` and `Alineo.load()` both use the same [`validateAgentSpec()`](https://github.com/DrejT/alineo/blob/main/packages/agent/src/schema.ts) function.

## Full example [#full-example]

```json
{
  "name": "code-reviewer",
  "title": "Code Reviewer Agent",
  "description": "A Node.js sandbox pre-loaded with TypeScript tooling for code review tasks.",
  "author": "acme <https://github.com/acme>",
  "categories": ["agent", "typescript"],

  "cli": "pi",
  "provider": "google",
  "model": "gemini-flash-latest",
  "packages": ["git"],
  "env": { "GEMINI_API_KEY": "${GEMINI_API_KEY}" },
  "resources": { "cpu": "500m", "memory": "512Mi" },

  "setup": [
    { "name": "install tools", "run": "npm install -g typescript eslint" },
    { "name": "make workspace", "run": "mkdir -p /workspace" }
  ],

  "registryDependencies": ["https://example.com/agents/base-node.json"]
}
```

There's no `image` field — every spec runs in a fixed `node:22` sandbox — and no `ports` field.

## Fields [#fields]

### Required [#required]

| Field  | Type     | Description                                                                                        |
| ------ | -------- | -------------------------------------------------------------------------------------------------- |
| `name` | `string` | Unique identifier. Used as the saved filename (`<name>.json`) and the sandbox session name.        |
| `cli`  | `"pi"`   | CLI to run inside the sandbox. Currently only `"pi"` is accepted — anything else fails validation. |

### Optional [#optional]

| Field                  | Type                                            | Description                                                                                                                                                                                                                                                                                              |
| ---------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title`                | `string`                                        | Human-readable display name.                                                                                                                                                                                                                                                                             |
| `description`          | `string`                                        | Shown by `alineo list`, falling back from `title` if `description` is unset.                                                                                                                                                                                                                             |
| `author`               | `string`                                        | Author name and optional URL.                                                                                                                                                                                                                                                                            |
| `categories`           | `string[]`                                      | Tags for discovery. Not currently used by any command — informational only.                                                                                                                                                                                                                              |
| `cliVersion`           | `string`                                        | npm version specifier for the Pi CLI (e.g. `"1.2.3"`, `"^1.2.0"`, or a dist-tag like `"latest"`). Passed to `npm install -g @earendil-works/pi-coding-agent@<cliVersion>`. Omit to install whatever npm resolves as latest. Included in the setup-hash cache key, so changing it forces a fresh install. |
| `provider`             | `string`                                        | AI provider passed to Pi via `--provider`. Omit for a direct Google API key (Pi's default).                                                                                                                                                                                                              |
| `model`                | `string`                                        | Model ID passed to Pi via `--model`.                                                                                                                                                                                                                                                                     |
| `packages`             | `string[]`                                      | APT packages installed before the CLI starts. `nodejs`/`nodejs_22` are silently ignored (the base image already has Node).                                                                                                                                                                               |
| `env`                  | `Record<string, string>`                        | Environment variables available inside the sandbox. Values may reference host env vars: `"${MY_API_KEY}"` → `process.env.MY_API_KEY` at load time.                                                                                                                                                       |
| `resources`            | `{ cpu: string; memory: string; gpu?: string }` | Falls back to `defaults.resources` in `alineo.config.json` if omitted (`1000m` / `1Gi` by default).                                                                                                                                                                                                      |
| `metadata`             | `Record<string, string>`                        | Not read anywhere in `alineo`; has no effect on the sandbox.                                                                                                                                                                                                                                             |
| `registryDependencies` | `string[]`                                      | URLs of other agent specs. `alineo add` fetches and saves each one first, depth-first, before saving this spec. No cycle detection.                                                                                                                                                                      |
| `setup`                | `SetupStep[]`                                   | Steps run inside the sandbox after CLI install, before the checkpoint. Each is `{ name, run, cwd? }` — `run` is a bash command, `cwd` optionally changes directory first. Any change here invalidates the setup-hash cache, forcing a fresh install+setup on the next `Alineo.load()`.                   |

## Validation [#validation]

Both `alineo add` and `Alineo.load()` call the same `validateAgentSpec()`. It checks four things:

* `name` is present and is a string.
* `cli` is exactly `"pi"`.
* If `spawnDepth` is set, it must be a non-negative integer.
* If `maxAgents` is set, it must be a non-negative integer.

Every other field is unchecked at validation time — a malformed `resources`, `setup` entry, or `env` value won't be caught until it's actually used (typically inside the sandbox, at load/run time).


---

# Cancellation & Cleanup
URL: /docs/examples/cancellation

Resource cleanup and error handling when commands fail or time out.



# cancellation [#cancellation]

Demonstrates resource cleanup and error handling patterns when commands fail or time out.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it shows [#what-it-shows]

| Pattern        | Description                                           |
| -------------- | ----------------------------------------------------- |
| `try/finally`  | Guarantees `sb.close()` runs even when an exec throws |
| Bash `timeout` | Limits a command's wall-clock time at the shell level |
| `CommandError` | Catching the error thrown by a non-zero exit code     |

Three sandboxes run sequentially, each demonstrating one pattern.


---

# Capturing Output
URL: /docs/examples/capture

Capture exec stdout and use it as input for subsequent steps.



# capture [#capture]

Demonstrates capturing exec stdout and using it as input for subsequent steps.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it does [#what-it-does]

1. Creates a `node:20-slim` sandbox
2. Runs a Node.js one-liner and captures its stdout (the Node version string)
3. Interpolates the captured value into a subsequent exec command
4. Writes a JSON file into the sandbox using the captured value
5. Reads the file back with `sb.readFile()` and prints it


---

# Control Flow
URL: /docs/examples/control-flow

retry, when, and forEach — alineo's built-in workflow control-flow primitives.



# control-flow [#control-flow]

Demonstrates all of alineo's built-in workflow control-flow primitives in a single sandbox.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it shows [#what-it-shows]

| Primitive | Description                                                    |
| --------- | -------------------------------------------------------------- |
| `retry`   | Retries a flaky command up to 5 times with exponential backoff |
| `when`    | Branches conditionally based on the previous exec's exit code  |
| `forEach` | Iterates over a list, running a command per item               |


---

# Reusable Environments
URL: /docs/examples/environments

Build a sandbox image once, then restore from snapshot on every run.



# environments [#environments]

Demonstrates sandbox environments: build a reusable sandbox image once, then restore from the snapshot on every subsequent run — skipping the setup entirely.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start        # ~30–60s first run (builds environment)
bun start        # ~2–3s on subsequent runs (restores from snapshot)
```

## What it does [#what-it-does]

**First run** — environment not yet cached:

1. Installs Python 3 and pip into a `debian:bookworm-slim` container
2. Installs `numpy` and `pandas` via pip
3. Snapshots the container and saves the snapshot ID to the ledger

**Subsequent runs** — environment cached:

1. Finds the existing snapshot in the ledger
2. Boots directly from the snapshot — no apt-get or pip install
3. Runs `import numpy, pandas` to prove packages are already present

The ledger entry is keyed by the environment name (`"python-data-science"`), so the snapshot is reused across process restarts.


---

# Error Handling
URL: /docs/examples/error-handling

Strict vs. non-strict exec, and the CommandError/SandboxError/ExecConnectionError types.



# error-handling [#error-handling]

Demonstrates the two error-handling modes for exec commands, and the error types you may encounter.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it shows [#what-it-shows]

| Pattern               | Description                                                                          |
| --------------------- | ------------------------------------------------------------------------------------ |
| Non-strict exec       | `exec("...", { strict: false })` returns the result; you inspect `exitCode` yourself |
| Strict exec (default) | Non-zero exit throws `CommandError` — catch it to handle the failure                 |
| Error types           | `CommandError`, `SandboxError`, `ExecConnectionError` — when each is thrown          |

Two sandboxes run sequentially, one per pattern.


---

# Code Interpreter
URL: /docs/examples/exec-code

Run code through the sandbox's built-in interpreter — stateless and stateful modes.



# exec-code [#exec-code]

Demonstrates `sb.execCode()` for running code through the sandbox's built-in interpreter — stateless one-shot calls and stateful sessions where variables persist across calls.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it shows [#what-it-shows]

| Mode      | Description                                                         |
| --------- | ------------------------------------------------------------------- |
| Stateless | Each `execCode()` call runs in an isolated context; no shared state |
| Stateful  | Calls sharing the same `context` object see each other's variables  |

Uses the `opensandbox/code-interpreter` image, which ships a Python interpreter accessible via the execd `/code` endpoint.


---

# File Operations
URL: /docs/examples/file-ops

The full sandbox file operations API — read, write, move, search, and transfer.



# file-ops [#file-ops]

Demonstrates the full sandbox file operations API — no `exec`/`sed` needed for common file tasks.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it covers [#what-it-covers]

| Method                                | Description                                           |
| ------------------------------------- | ----------------------------------------------------- |
| `writeFile` / `readFile`              | Write and read UTF-8 files                            |
| `moveFile` / `deleteFile`             | Move or remove a file                                 |
| `createDirectory` / `deleteDirectory` | Create or remove directories                          |
| `listDirectory`                       | List directory contents                               |
| `searchFiles`                         | Find files matching a glob pattern                    |
| `getFileInfo`                         | Stat a file (size, type, mode, timestamps)            |
| `replaceInFiles`                      | In-place string substitution across one or more files |
| `transfer`                            | Copy a file from one sandbox to another               |

Two sandboxes are used to demonstrate `transfer()`.


---

# Hello World
URL: /docs/examples/hello-world

Spin up an Ubuntu sandbox, run a command, and stream the output.



# hello-world [#hello-world]

The simplest alineo example: spin up an Ubuntu sandbox, run `echo "hello world"`, and stream the output.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it does [#what-it-does]

1. Creates an Ubuntu 22.04 sandbox
2. Executes `echo "hello world"` inside it
3. Streams output to stdout
4. Deletes the sandbox on completion

## Notes [#notes]

All examples default to `useServerProxy: true` — traffic routes through the OpenSandbox server so Docker bridge IPs don't need to be reachable directly. Set `USE_SERVER_PROXY=false` to disable (e.g. when using `uvx opensandbox-server` on the host).


---

# Examples
URL: /docs/examples

Runnable examples covering every corner of the alineo SDK — clone, install, and run.



Every example below is a real, runnable package in the [alineo repo](https://github.com/DrejT/alineo/tree/main/examples) — `bunx alineo-cli init`, `bun install`, `bun start`.

<Cards>
  <Card href="/docs/examples/hello-world" title="Hello World" description="Spin up an Ubuntu sandbox, run a command, and stream the output." />

  <Card href="/docs/examples/exec-code" title="Code Interpreter" description="Run code through the sandbox's built-in interpreter — stateless and stateful modes." />

  <Card href="/docs/examples/file-ops" title="File Operations" description="The full sandbox file operations API — read, write, move, search, and transfer." />

  <Card href="/docs/examples/read-file" title="Reading Files" description="Read a file written inside the sandbox back to the host process." />

  <Card href="/docs/examples/capture" title="Capturing Output" description="Capture exec stdout and use it as input for subsequent steps." />

  <Card href="/docs/examples/run-bash-script" title="Running Bash Scripts" description="Run a multi-line bash script inside an isolated sandbox and stream its output." />

  <Card href="/docs/examples/interactive-exec" title="Interactive Exec" description="A live, bidirectional PTY session that can be checkpointed and resumed mid-session." />

  <Card href="/docs/examples/ports" title="Exposing Ports" description="Start an HTTP server inside a sandbox and reach it from the host via sb.proxy()." />

  <Card href="/docs/examples/control-flow" title="Control Flow" description="retry, when, and forEach — alineo's built-in workflow control-flow primitives." />

  <Card href="/docs/examples/cancellation" title="Cancellation & Cleanup" description="Resource cleanup and error handling when commands fail or time out." />

  <Card href="/docs/examples/error-handling" title="Error Handling" description="Strict vs. non-strict exec, and the CommandError/SandboxError/ExecConnectionError types." />

  <Card href="/docs/examples/environments" title="Reusable Environments" description="Build a sandbox image once, then restore from snapshot on every run." />

  <Card href="/docs/examples/snapshot-replay" title="Checkpoint & Resume" description="Capture a snapshot after install, then resume from it to skip setup on the next run." />

  <Card href="/docs/examples/sandbox-fork" title="Forking Sandboxes" description="Install dependencies once, then fork into independent sandboxes that run in parallel." />
</Cards>


---

# Interactive Exec
URL: /docs/examples/interactive-exec

A live, bidirectional PTY session that can be checkpointed and resumed mid-session.



# interactive-exec [#interactive-exec]

Demonstrates `sb.exec(cmd, { interactive: true })`: a live, bidirectional PTY session that can be driven like a human — and resumed like one too.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it does [#what-it-does]

1. Creates an `ubuntu:22.04` sandbox and opens an interactive `bash` session
2. Drives it with several `write()` calls — exports a var, `cd`s into a directory, writes a file — none of which is a single self-contained command
3. Checkpoints the sandbox **while the shell is still open** (not after it exits)
4. Resumes from that checkpoint into a new sandbox
5. Opens the same interactive exec again at the same call site — the resume path detects the session was still open, replays its recorded stdin for real against the freshly restored filesystem, then hands control back live
6. Asserts the `cd`, the file contents, and the exported variable all survived — reconstructed by re-running the recorded input, not by faking a transcript
7. Exits the shell and asserts the interactive exec resolves with the process's real exit code

OpenSandbox snapshots are rootfs-only (no CRIU) — the original bash process is provably gone after resume. Reconstructing shell state is only possible by replaying the stdin that produced it.


---

# Exposing Ports
URL: /docs/examples/ports

Start an HTTP server inside a sandbox and reach it from the host via sb.proxy().



# ports [#ports]

Demonstrates `sb.proxy()`: start an HTTP server inside a sandbox and send requests to it from the host process via the OpenSandbox server proxy.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it does [#what-it-does]

1. Creates a `node:22` sandbox and writes a simple HTTP server to `/server.js`
2. Starts the server on port 3000 in the background
3. Calls `sb.proxy(3000)` to get a proxy URL and auth headers
4. Sends two requests from the host process and prints the JSON responses

## Notes [#notes]

With `useServerProxy: true` (the default), `sb.proxy()` returns a URL that routes through the OpenSandbox server (`http://127.0.0.1:8080/sandboxes/{id}/proxy/3000`). This works regardless of Docker networking because the server relays the request to the container on your behalf.


---

# Reading Files
URL: /docs/examples/read-file

Read a file written inside the sandbox back to the host process.



# read-file [#read-file]

Demonstrates `sb.readFile()` — reading a file written inside the sandbox back to the host process.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it does [#what-it-does]

1. Creates a `node:20-slim` sandbox
2. Runs a Node.js one-liner that writes the Node version to `/tmp/version.txt`
3. Reads the file back using `sb.readFile()`
4. Writes a JSON report to the sandbox using `sb.writeFile()` and reads it back
5. Prints both captured values to the console


---

# Running Bash Scripts
URL: /docs/examples/run-bash-script

Run a multi-line bash script inside an isolated sandbox and stream its output.



# run-bash-script [#run-bash-script]

Run a multi-line bash script inside an isolated sandbox and stream its output.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it does [#what-it-does]

1. Creates an Ubuntu 22.04 sandbox
2. Runs a bash script that prints system info, disk usage, and writes/reads a file
3. Streams output to stdout as it arrives
4. Deletes the sandbox on completion


---

# Forking Sandboxes
URL: /docs/examples/sandbox-fork

Install dependencies once, then fork into independent sandboxes that run in parallel.



# sandbox-fork [#sandbox-fork]

Demonstrates `sb.fork()`: install dependencies once into a base sandbox, then branch into two independent sandboxes that run different workloads in parallel — without repeating the install.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it does [#what-it-does]

1. Creates a `python:3.11-slim` sandbox and installs `numpy`
2. Forks into two independent sandboxes (`track-a`, `track-b`) from the post-install state
3. Runs a different numpy computation on each fork in parallel
4. Lists the checkpoints recorded on the original sandbox
5. Closes all three sandboxes

Both forks start with numpy already installed — neither pays the pip install cost again.


---

# Checkpoint & Resume
URL: /docs/examples/snapshot-replay

Capture a snapshot after install, then resume from it to skip setup on the next run.



# snapshot-replay [#snapshot-replay]

Demonstrates alineo's checkpoint and resume feature: run once to install dependencies and capture a snapshot, then resume from that snapshot — skipping the install — to run updated code against the same environment.

## Setup [#setup]

```bash
bunx alineo-cli init   # starts OpenSandbox in Docker (one-time setup)
```

## Run [#run]

```bash
bun install
bun start
```

## What it does [#what-it-does]

**Initial run**

1. Creates a Python 3.11 sandbox
2. Installs `requests` via pip
3. Captures a checkpoint (`after-install`)
4. Runs a script and closes the sandbox

**Resumed run**

1. Calls `client.resume(sandboxId)` — boots from the snapshot
2. The `pip install` call returns cached output instantly (never re-runs)
3. Runs an updated script on the restored container

Run `bun start` twice to see the difference: first run takes \~30–60s; the resume takes \~2–3s.


---

# AI Agent Bugfix
URL: /docs/cookbooks/ai-agent-bugfix

An agent that debugs and fixes a failing test on its own, then gets independently verified.



<CookbookMeta difficulty="Intermediate" time="~10 min" primitives="[&#x22;Alineo.load()&#x22;, &#x22;agent.prompt()&#x22;, &#x22;agent.bash()&#x22;]" />

An AI agent that debugs a failing test and fixes the bug itself — inside its own sandbox, using
nothing but bash and a model.

## Setup [#setup]

<Steps>
  <Step>
    Start OpenSandbox in Docker (one-time setup):

    ```bash
    bunx alineo-cli init
    ```
  </Step>

  <Step>
    Get a free API key from [build.nvidia.com](https://build.nvidia.com) and export it:

    ```bash
    export NVIDIA_API_KEY=...
    ```
  </Step>
</Steps>

## Run it [#run-it]

<CookbookPlayground
  cwd="cookbooks/ai-agent-bugfix"
  repoHref="https://github.com/DrejT/alineo/tree/main/cookbooks/ai-agent-bugfix"
  commands="[
  { command: &#x22;bun install&#x22; },
  { command: &#x22;bun start&#x22; },
]"
  output="`Planting an off-by-one bug in calc.py...
Running pytest — 1 failed (as expected)

Agent: reading calc.py... found it — range(n) should be range(n + 1). Patching...
Agent: re-running pytest inside the sandbox — 2 passed. Reporting done.

Independent verification (agent.sandbox.exec, not the agent's own claim):
pytest ................. 2 passed ✔`"
/>

## What it does [#what-it-does]

<Steps>
  <Step>
    Plants a deliberate off-by-one bug in `calc.py` and a test that catches it.
  </Step>

  <Step>
    Runs `pytest` via `agent.bash()` to show the failure.
  </Step>

  <Step>
    Prompts the agent — via `alineo`'s `Alineo.load()` + `agent.prompt()` — to find and fix the bug
    itself, streaming its reasoning and tool calls as they happen.
  </Step>

  <Step>
    **Re-runs `pytest` independently of the agent** (via `agent.sandbox.exec()`) to verify the fix,
    rather than trusting the agent's own claim that it passed.
  </Step>
</Steps>

`agents/bugfix-agent.json` configures a Pi agent on `python:3.11-slim` using the NVIDIA NIM API.
Swap `provider`/`model` for anything in `@alineo-labs/model-providers` to use a different key.

<Callout type="warn" title="Never trust the agent's self-report">
  Step 4 is the important part of this recipe: never trust an agent's self-report that a fix worked
  — re-run the check yourself against the sandbox it was working in.
</Callout>

<Callout title="Where to go next">
  See the [Agent SDK docs](/docs/agent) for the full `alineo` API (`prompt`, `bash`, `steer`,
  `fork`, model switching, and more), and
  [examples/pi-agent](https://github.com/DrejT/alineo/tree/main/examples/pi-agent) for a tour of
  every command it exposes.
</Callout>


---

# CI Test Runner
URL: /docs/cookbooks/ci-test-runner

Run a repo's test suite in a disposable sandbox and turn the output into a structured pass/fail report.



<CookbookMeta difficulty="Beginner" time="~5 min" primitives="[&#x22;exec&#x22;, &#x22;strict: false&#x22;]" />

Run a repo's test suite in a disposable, CI-style sandbox and turn the raw output into a
structured pass/fail report — the building block for a PR check, a bot, or an agent that needs to
know "did the tests pass" without scraping a log.

## Setup [#setup]

<Steps>
  <Step>
    Start OpenSandbox in Docker (one-time setup):

    ```bash
    bunx alineo-cli init
    ```
  </Step>
</Steps>

## Run it [#run-it]

<CookbookPlayground
  cwd="cookbooks/ci-test-runner"
  repoHref="https://github.com/DrejT/alineo/tree/main/cookbooks/ci-test-runner"
  commands="[
  { command: &#x22;bun install&#x22; },
  { command: &#x22;bun start&#x22; },
]"
  output="`Scaffolding calc.py + test_calc.py in the sandbox...
Installing pytest...
Running pytest...

{
&#x22;status&#x22;: &#x22;failed&#x22;,
&#x22;summary&#x22;: &#x22;1 passed, 1 failed&#x22;,
&#x22;output&#x22;: &#x22;FAILED test_calc.py::test_subtract - assert 1 == 2&#x22;
}

process.exitCode = 1`"
/>

## What it does [#what-it-does]

<Steps>
  <Step>
    Scaffolds a tiny Python project (`calc.py` + `test_calc.py`) directly in the sandbox — one test
    deliberately fails, so you see a real failure report.
  </Step>

  <Step>
    Installs `pytest`.
  </Step>

  <Step>
    Runs the suite with `strict: false` so a non-zero exit is data, not a thrown error.
  </Step>

  <Step>
    Prints a structured report (`status`, `summary`, and the full output on failure) and sets
    `process.exitCode` accordingly — the same shape a CI step or bot would check.
  </Step>
</Steps>

<Callout title="Point this at a real repository">
  Swap the "scaffold a project" step for a real clone:

  ```ts
  await sb.exec(`git clone --depth 1 ${repoUrl} /workspace`);
  ```
</Callout>

<Callout type="warn" title="Server proxy">
  All examples default to `useServerProxy: true` — traffic routes through the OpenSandbox server so
  Docker bridge IPs don't need to be reachable directly. Set `USE_SERVER_PROXY=false` to disable
  (e.g. when using `uvx opensandbox-server` on the host).
</Callout>


---

# Credential-Scoped Agent
URL: /docs/cookbooks/credential-scoped-agent

An agent that calls an authenticated API with a token it can never read — injected at the egress layer.



<CookbookMeta difficulty="Advanced" time="~10 min" primitives="[&#x22;credentialProxy&#x22;, &#x22;Credential Vault&#x22;, &#x22;egress injection&#x22;]" />

An agent that does real work against an authenticated API — GitHub, here — using a token it can
never read. The token is registered as a **credential**, not an environment variable: it's
injected into matching outbound requests at the egress layer, so the agent can call
`api.github.com` as you while the value itself never enters the container's filesystem or
environment. Revoking it takes effect immediately, mid-session, without touching the running
sandbox.

## Setup [#setup]

<Steps>
  <Step>
    Start OpenSandbox in Docker (one-time setup):

    ```bash
    bunx alineo-cli init
    ```
  </Step>

  <Step>
    Export the agent's model key and a GitHub token:

    ```bash
    export NVIDIA_API_KEY=...   # https://build.nvidia.com — free tier
    export GH_TOKEN=...         # a GitHub PAT (classic or fine-grained); read-only scopes are enough
    ```

    `GH_TOKEN` is just the raw token — the agent spec (`agents/github-agent.json`) wraps it as
    `Bearer ${GH_TOKEN}` so the injected `Authorization` header is well-formed for GitHub.
  </Step>

  <Step>
    Credential injection rides on the same egress layer as network policy, so the server needs
    `egress.image` configured and `egress.mode = "dns+nft"` — the default for `alineo init` since
    this feature landed. On an older local config, add this to `~/.config/alineo/server.toml` and
    restart the server:

    ```toml
    [egress]
    image = "opensandbox/egress:v1.1.7"
    mode = "dns+nft"
    ```
  </Step>
</Steps>

## Run it [#run-it]

<CookbookPlayground
  cwd="cookbooks/credential-scoped-agent"
  repoHref="https://github.com/DrejT/alineo/tree/main/cookbooks/credential-scoped-agent"
  commands="[
  { command: &#x22;bun install&#x22; },
  { command: &#x22;bun start&#x22; },
]"
  output="`Loading agents/github-agent.json — GITHUB_TOKEN is a credential binding, not a plain env var.
credentialProxy: true — token registered with the egress sidecar's Credential Vault.

Agent: curl https://api.github.com/user
→ 200 OK — authenticated as &#x22;your-username&#x22;

Audit — env | grep TOKEN inside the sandbox: (nothing)
Audit — bare curl with no Authorization header: 200 OK (injected at the sidecar, not by the agent)

Revoking GITHUB_TOKEN via agent.sandbox.credentials.remove(&#x22;GITHUB_TOKEN&#x22;)...
Audit — same bare request, again: 401 Unauthorized`"
/>

## What it does [#what-it-does]

<Steps>
  <Step>
    **`Alineo.load()` reads `agents/github-agent.json`**, whose `env.GITHUB_TOKEN` is a
    `{ credential, host, injection }` binding rather than a string. Because at least one binding is
    present, `load()` creates the sandbox with `credentialProxy: true` and registers the token with
    the egress sidecar's Credential Vault — it never becomes a container env var.
    `env.NVIDIA_API_KEY`, an ordinary string, is exported the normal way.
  </Step>

  <Step>
    **The agent does authenticated GitHub work** — told to `curl https://api.github.com/user` and
    report the login, it does — with no token and no `Authorization` header of its own, and the call
    comes back authenticated as you. The recipe echoes the agent's `bash` command and its output, so
    you see the real request and the real response.
  </Step>

  <Step>
    **Audit** — `env | grep` inside the sandbox turns up nothing, and a bare
    `curl https://api.github.com/user` with no `Authorization` header of its own still returns
    `HTTP 200`: the credential reached the request at the sidecar, not through anything the agent
    could see.
  </Step>

  <Step>
    **Revoke** — `agent.sandbox.credentials.remove("GITHUB_TOKEN")`, and the same bare request now
    returns `HTTP 401`. The agent's own next call to `api.github.com` would fail the same way.
  </Step>
</Steps>

<Callout title="The point">
  `AgentSpec.env` is the obvious place to hand an agent a secret, but a plain env var is readable by
  anything running in the sandbox — including code the agent wrote itself, and including a
  prompt-injected instruction to print it. A credential binding gives the agent the *capability*
  (authenticated calls to one host) without the *secret*, and leaves you holding the leash: one
  `remove()` call cuts access at the network layer, with no redeploy and nothing to clean up inside
  the container.
</Callout>

<Accordions>
  <Accordion title="Where to go next">
    * [`examples/credential-injection`](https://github.com/DrejT/alineo/tree/main/examples/credential-injection)
      — the same mechanism at the raw `Sandbox` level (no agent), plus `fork()` carrying a bound
      credential to the child automatically and the `source` / `resolveCredential` contract for
      `resume()`.
    * [Credentials](https://docs.alineo.tech/docs/core/concepts/credentials) in the docs for the full
      `sb.credentials.*` API, `pathPrefix` scoping, and how bindings behave across
      `resume()` / `fork()` / spawned children.
  </Accordion>

  <Accordion title="Binding types and injection modes">
    The binding here uses `credential: "Bearer ${GH_TOKEN}"`, an interpolated string rather than a
    bare `${GH_TOKEN}` — so its `CredentialSource` is `{ type: "external" }`, not `{ type: "env" }`.
    That only matters for `resume()` / `fork()` (which would then need an explicit
    `resolveCredential` callback); this recipe loads the agent, uses it, and closes it, so it never
    comes up. Use a bare `${GH_TOKEN}` (and a token that already includes its scheme) if you want
    env-based auto-resolution.

    Two `injection` types are supported: `{ type: "header", name }` (the common case, shown above)
    and `{ type: "substitution", placeholder, in }`, which replaces a literal placeholder string in
    the request's path/query/header/body with the value — use it for APIs that take the key in the
    URL. The request must already contain the placeholder verbatim (e.g. put it in a base URL).
  </Accordion>
</Accordions>


---

# Cookbooks
URL: /docs/cookbooks

Task-oriented recipes for building with alineo — composed from the SDK's primitives to solve real end-to-end problems.



Every recipe below is a real, runnable package in the
[alineo repo](https://github.com/DrejT/alineo/tree/main/cookbooks) — `cd cookbooks/<recipe>`,
`bun install`, `bun start`. Where [Examples](/docs/examples) demonstrates one SDK primitive at a
time, a cookbook recipe composes several of them to solve one real task. Each page below has an
in-browser preview of what running it looks like, plus a step-by-step walkthrough of what's
actually happening.

<CookbookGrid />


---

# Parallel Test Shards
URL: /docs/cookbooks/parallel-test-shards

Install dependencies once, then fork into parallel sandboxes to shard work across them.



<CookbookMeta difficulty="Intermediate" time="~5 min" primitives="[&#x22;fork&#x22;, &#x22;Promise.all&#x22;]" />

Install dependencies once, then fork the sandbox into N independent copies that each run a shard
of the test suite in parallel — cutting wall-clock time roughly by the number of shards, without
repeating the install in every shard.

## Setup [#setup]

<Steps>
  <Step>
    Start OpenSandbox in Docker (one-time setup):

    ```bash
    bunx alineo-cli init
    ```
  </Step>
</Steps>

## Run it [#run-it]

<CookbookPlayground
  cwd="cookbooks/parallel-test-shards"
  repoHref="https://github.com/DrejT/alineo/tree/main/cookbooks/parallel-test-shards"
  commands="[
  { command: &#x22;bun install&#x22; },
  { command: &#x22;bun start&#x22; },
]"
  output="`Base sandbox: installing pytest, writing 3 test files...
Forking into 3 shards...

shard-1 exit 0 2 passed
shard-2 exit 0 2 passed
shard-3 exit 1 1 passed, 1 failed

Overall: FAILED (2/3 shards green)`"
/>

## What it does [#what-it-does]

<Steps>
  <Step>
    Creates one base sandbox, installs `pytest`, and writes three small test files to it.
  </Step>

  <Step>
    Calls `base.fork()` three times — each fork branches off the base sandbox's state, so none of
    them repeat the `pip install`.
  </Step>

  <Step>
    Runs a different test file in each fork with `Promise.all`, in parallel.
  </Step>

  <Step>
    Aggregates each shard's exit code and a one-line summary into an overall pass/fail report.
  </Step>
</Steps>

<Callout title="Generalizing to a real repo">
  Install dependencies and discover shard boundaries once in the base sandbox, then fork once per
  shard (or per CPU core) instead of paying setup cost N times. See [Forking
  Sandboxes](/docs/examples/sandbox-fork) for the underlying `sb.fork()` primitive.
</Callout>

<Callout type="warn" title="Server proxy">
  All examples default to `useServerProxy: true` — traffic routes through the OpenSandbox server so
  Docker bridge IPs don't need to be reachable directly. Set `USE_SERVER_PROXY=false` to disable
  (e.g. when using `uvx opensandbox-server` on the host).
</Callout>


---

# Persistent Agent Memory
URL: /docs/cookbooks/persistent-agent-memory

An agent that remembers things about a customer across separate sandbox sessions, via @alineo-labs/memory.



<CookbookMeta difficulty="Advanced" time="~10 min" primitives="[&#x22;@alineo-labs/memory&#x22;, &#x22;resourceRef&#x22;, &#x22;remember()&#x22;]" />

A support agent that remembers things about a customer across sessions — not within one
conversation (Pi already keeps that), but across separate sandbox sessions entirely, backed by a
real, persisted `@alineo-labs/memory` store.

## Setup [#setup]

<Steps>
  <Step>
    Start OpenSandbox in Docker (one-time setup):

    ```bash
    bunx alineo-cli init
    ```
  </Step>

  <Step>
    Get a free API key from [build.nvidia.com](https://build.nvidia.com) and export it:

    ```bash
    export NVIDIA_API_KEY=...
    ```
  </Step>
</Steps>

## Run it [#run-it]

<CookbookPlayground
  cwd="cookbooks/persistent-agent-memory"
  repoHref="https://github.com/DrejT/alineo/tree/main/cookbooks/persistent-agent-memory"
  commands="[
  { command: &#x22;bun install&#x22; },
  { command: &#x22;bun start&#x22; },
]"
  output="`── session 1 (sandboxId: a1b2c3) ──
Working-memory profile set: { plan: &#x22;pro&#x22;, name: &#x22;Jordan&#x22; }
remember(): &#x22;upgraded to Pro on the 3rd&#x22; — sourceRef verified against the ledger ✔

── session 2 (sandboxId: d4e5f6 — a brand-new sandbox) ──
buildContextSnippet() → &#x22;Jordan is on the Pro plan. Upgraded on the 3rd (verified).&#x22;
Agent: &#x22;Hi Jordan — since you're on Pro, you already have access to that feature.&#x22;`"
/>

## What it does [#what-it-does]

<Steps>
  <Step>
    **Session 1** — loads `agents/support-agent.json` via `Alineo.load()`, wired with a `Memory`
    instance backed by `@alineo-labs/sqlite-memory` (a real file: `./.alineo/agent-memory.db`). Sets
    a working-memory profile (`plan`, `name`), runs a command, and `remember()`s a fact tagged with a
    `sourceRef` pointing at the real ledger entry that command produced — so it comes back
    `verified: true`, not just claimed. Prompts the agent, then closes the sandbox.
  </Step>

  <Step>
    **Session 2** — calls `Alineo.load()` again with the **same spec** and the **same `Memory`
    instance**. This creates a brand-new sandbox (a different `sandboxId` — you'll see it in the
    output) with none of session 1's container state. Everything recalled — the working-memory
    profile, the verified fact — comes back purely because `agent.resourceRef` (which defaults to the
    agent's own `name`) is the same resource as session 1's, not because anything about the sandbox
    itself was preserved.
  </Step>

  <Step>
    Uses `buildContextSnippet()` to assemble what's known about the customer into a plain-text block,
    prepended to the second session's prompt — so the agent's reply is grounded in real memory, not
    asked to guess.
  </Step>
</Steps>

<Callout title="The point">
  Sandboxes are already durable — `sb.checkpoint()`/`resume()` preserve container state within one
  logical session. What this recipe shows is a different kind of durability: memory that survives
  past the sandbox session it was learned in entirely, addressed by a stable identity
  (`resourceRef`) instead of a `sandboxId`.
</Callout>

<Accordions>
  <Accordion title="Where to go next">
    * [`examples/memory-basics`](https://github.com/DrejT/alineo/tree/main/examples/memory-basics) —
      every `@alineo-labs/memory` capability demonstrated standalone, no OpenSandbox needed:
      compaction, `SchemaWorkingMemory`, `episodicTree()`, `Memory.fork()` (which `Alineo.spawn()`
      calls automatically — not exercised in this recipe, since it needs a second agent spec and a
      `spawnDepth` budget beyond what this recipe's scope covers), and team access control.
    * [`@alineo-labs/memory`'s own README](https://github.com/DrejT/alineo/tree/main/packages/memory)
      for the full API.
  </Accordion>

  <Accordion title="Notes on the embedding provider">
    The embedding call in `index.ts` (`nvidiaEmbeddings()`) is inlined rather than imported from
    `@alineo-labs/model-providers`, since that package is private to this repo's own dashboard app —
    not meant to be depended on from a cookbook someone copies out of this repo. Swap it for any
    `EmbeddingProvider` (a different provider's API, a local model) with no other changes needed.
  </Accordion>
</Accordions>


---

# Resumable ETL Pipeline
URL: /docs/cookbooks/resumable-etl-pipeline

A multi-stage pipeline that checkpoints after every stage and resumes without redoing completed work.



<CookbookMeta difficulty="Intermediate" time="~5 min" primitives="[&#x22;checkpoint&#x22;, &#x22;resume&#x22;]" />

A multi-stage ETL pipeline (extract → transform → load) that checkpoints after each stage, so a
crash — or just wanting to re-run "load" in isolation — doesn't mean paying for extract and
transform again.

## Setup [#setup]

<Steps>
  <Step>
    Start OpenSandbox in Docker (one-time setup):

    ```bash
    bunx alineo-cli init
    ```
  </Step>
</Steps>

## Run it [#run-it]

<CookbookPlayground
  cwd="cookbooks/resumable-etl-pipeline"
  repoHref="https://github.com/DrejT/alineo/tree/main/cookbooks/resumable-etl-pipeline"
  commands="[
  { command: &#x22;bun install&#x22; },
  { command: &#x22;bun start&#x22; },
]"
  output="`── original run ──
extract    installed pandas, wrote raw.csv         checkpoint: after-extract
transform  aggregated revenue by region             checkpoint: after-transform
load       published transformed.csv ✔

── resumed run (client.resume(sandboxId)) ──
restored from checkpoint &#x22;after-transform&#x22;
[replayed] pip install pandas ← instant, from the ledger, no network call
load transformed.csv already on disk — published ✔`"
/>

## What it does [#what-it-does]

<Tabs items="['Original run', 'Resumed run']">
  <Tab value="Original run">
    <Steps>
      <Step>
        **Extract** — installs `pandas`, writes raw CSV data, then `sb.checkpoint("after-extract")`.
      </Step>

      <Step>
        **Transform** — aggregates revenue by region with pandas, writes `transformed.csv`, then
        `sb.checkpoint("after-transform")`.
      </Step>

      <Step>
        **Load** — reads back the final output and "publishes" it (prints it).
      </Step>
    </Steps>
  </Tab>

  <Tab value="Resumed run">
    Simulates picking the pipeline back up later:

    <Steps>
      <Step>
        `client.resume(sandboxId)` restores the container from the last checkpoint (`after-transform`).
      </Step>

      <Step>
        The first `exec()` call after resume replays instantly from the ledger — no network call, no
        re-install. This demo re-issues the identical `pip install` command from the extract stage as a
        matter of good practice; the replay itself is positional (see the note below), not a check that
        the command matches.
      </Step>

      <Step>
        `transformed.csv` is already present on the restored container's filesystem, so the load stage
        reads it straight away — the transform never re-runs.
      </Step>
    </Steps>
  </Tab>
</Tabs>

<Callout type="warn" title="Replay is positional, not content-matched">
  Every `sb.checkpoint(tag)` is a real container snapshot, not just a ledger bookmark — the restored
  container genuinely has extract and transform's output on disk. The ledger replay on top of that
  is an optimization: the Nth `exec()` call since resume returns the Nth call's original result,
  whatever command is actually passed — there's no check that it matches what was recorded. Always
  re-issue calls in the same order as the original run. See [Checkpoint &
  Resume](/docs/examples/snapshot-replay) for the primitive this recipe builds on.
</Callout>

<Callout type="warn" title="Server proxy">
  All examples default to `useServerProxy: true` — traffic routes through the OpenSandbox server so
  Docker bridge IPs don't need to be reachable directly. Set `USE_SERVER_PROXY=false` to disable
  (e.g. when using `uvx opensandbox-server` on the host).
</Callout>


---

# Untrusted Code Execution
URL: /docs/cookbooks/untrusted-code-execution

Safely run LLM-generated or user-submitted code — per-snippet isolation, resource caps, and timeouts.



<CookbookMeta difficulty="Beginner" time="~5 min" primitives="[&#x22;exec&#x22;, &#x22;resources&#x22;, &#x22;timeouts&#x22;]" />

Safely execute untrusted or LLM-generated Python snippets — each one gets its own throwaway,
resource-capped sandbox with a wall-clock timeout, and failures are captured as data instead of
crashing the batch.

## Setup [#setup]

<Steps>
  <Step>
    Start OpenSandbox in Docker (one-time setup):

    ```bash
    bunx alineo-cli init
    ```
  </Step>
</Steps>

## Run it [#run-it]

<CookbookPlayground
  cwd="cookbooks/untrusted-code-execution"
  repoHref="https://github.com/DrejT/alineo/tree/main/cookbooks/untrusted-code-execution"
  commands="[
  { command: &#x22;bun install&#x22; },
  { command: &#x22;bun start&#x22; },
]"
  output="`▶ well-behaved     exit 0   &#x22;the answer is 42&#x22;
▶ raises           exit 1   Traceback (most recent call last):
                            ValueError: something went wrong
▶ infinite-loop    exit 124 timed out after 5000ms

3 snippets run — 1 succeeded, 2 failed (as expected)`"
/>

## What it does [#what-it-does]

Runs three Python snippets in parallel, each in its own sandbox:

<Steps>
  <Step>
    **`well-behaved`** — a snippet that just prints a value.
  </Step>

  <Step>
    **`raises`** — a snippet that raises an uncaught exception.
  </Step>

  <Step>
    **`infinite-loop`** — a snippet that never terminates on its own.
  </Step>
</Steps>

Each sandbox is created with tight `resources` (`250m` CPU / `128Mi` memory), a 30s container
`timeout`, and a 5s `timeoutMs` on the exec call itself. `strict: false` means a non-zero exit is
returned as data (`exitCode`, `stdout`, `stderr`) instead of throwing, so one bad snippet doesn't
abort the batch. Every sandbox is closed in `finally`, even on timeout or crash.

<Callout title="Reach for this pattern when...">
  You're executing code you didn't write yourself — an LLM's generated code, a user-submitted
  script, a plugin. Swap `runUntrusted()`'s body for `execCode()`/`createCodeContext()` (see [Code
  Interpreter](/docs/examples/exec-code)) if you want a stateful REPL instead of one-shot scripts.
</Callout>

<Callout type="warn" title="Server proxy">
  All examples default to `useServerProxy: true` — traffic routes through the OpenSandbox server so
  Docker bridge IPs don't need to be reachable directly. Set `USE_SERVER_PROXY=false` to disable
  (e.g. when using `uvx opensandbox-server` on the host).
</Callout>
