Storage

The ledger behind every sandbox — choosing SQLite or Postgres, what gets written, and the schema both create.

Every sandbox created, every command run and every chunk it emitted is appended to a ledger as it happens. That is what makes a run resumable: client.resume() rebuilds its replay cache from it, and an auditor gets the whole history rather than a summary of it.

The ledger lives behind one interface, IStorageAdapter, with two implementations:

AdapterPackageUse it for
SQLiteAdapter@alineo-labs/sqliteLocal development, single-process apps, scripts, tests. Nothing to run.
PostgresAdapter@alineo-labs/postgresSeveral processes sharing one ledger, hosts with no persistent local disk.

Switching between them is a one-line change. Anything else — write your own.

SQLite

bun add @alineo-labs/sqlite
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"),
});

new SQLiteAdapter(path) creates the file if it doesn't exist. Pass ":memory:" for an in-memory ledger — a clean one per test run, gone on close. WAL mode is switched on the first time the adapter is used, so readers never block writers and concurrent sandboxes in one process are safe.

Postgres

bun add @alineo-labs/postgres
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!),
});

new PostgresAdapter(connectionString) takes a URL of the form postgres://user:password@host:5432/database. The postgres driver underneath also reads PGHOST, PGPORT, PGUSER, PGPASSWORD and PGDATABASE, so the connection can come from the environment instead. Keep credentials there, never in code.

No connect, no close

You never call connect() or close() on an adapter. It initialises on first use — running its CREATE TABLE IF NOT EXISTS migrations, so constructing one fresh on every startup is safe — and closes itself from a beforeExit hook when the event loop drains. A long-running server never drains, so its pool stays open for the life of the process; that is expected, not a leak.

(Sandbox has a connect() of its own — client.connect(), for attaching to an already-running sandbox by ID. It is unrelated to the adapter.)

What gets written

EventWhen
sandbox.createdThe container was created and reached Running.
exec.startedsb.exec() or sb.execCode() was called.
exec.outputA stdout or stderr chunk arrived from execd.
exec.completedThe command finished; the payload has { seq, exitCode }.
sandbox.checkpoint_createdsb.checkpoint() completed; the payload has { snapshotId }.
sandbox.pausedsb.pause() froze the container.
sandbox.resumedsb.resume() thawed it.
egress.rule_addedA network policy rule was added at runtime.
egress.rule_removedA network policy rule was removed.
credential.boundA credential was bound to the sandbox.
credential.revokedA credential binding was revoked.
sandbox.closedsb.close() was called.

Execs replayed from cache in a resumed sandbox return their stored output and write nothing new. The Agent SDK appends its own events — permission requests, turn checkpoints — to the same ledger.

Schema

Both adapters create the same two tables. SQLite:

CREATE TABLE IF NOT EXISTS alineo_events (
  id          INTEGER  PRIMARY KEY,
  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_name_sandbox_ts
  ON alineo_events(name, sandbox_id, ts, id);

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
);

Postgres is the same shape with BIGSERIAL ids, BIGINT timestamps and a JSONB payload. Every hot read filters on (name, sandbox_id) and orders by ts, which the one composite index covers end to end; an existing database's older single-column indexes are dropped on connect.