---
title: Boat
description: Run persistent Ubuntu VMs for long-running agents on Boat. Size presets, durable background processes, and cookie-authenticated previews.
---

`boat()` runs each sandbox as a persistent Ubuntu VM on [Boat](https://boat.dev), with Node.js, Git, and Docker installed.

## Install

**npm**

```bash
npm install @opencoredev/sandbox-sdk
```

**pnpm**

```bash
pnpm add @opencoredev/sandbox-sdk
```

**yarn**

```bash
yarn add @opencoredev/sandbox-sdk
```

**bun**

```bash
bun add @opencoredev/sandbox-sdk
```

The adapter calls Boat's HTTP API itself, so there is nothing else to install. Set `BOAT_API_KEY` on your server, and keep it out of browser bundles.

## Quickstart

```ts
import { createSandbox } from "@opencoredev/sandbox-sdk";
import { boat } from "@opencoredev/sandbox-sdk/boat";

await using box = await createSandbox(boat());
const result = await box.run("uname -a");
console.log(result.stdout);
```

`await using` deletes the VM when the block ends.

## What it supports

| Feature                        | Support     | Notes                                   |
| ------------------------------ | ----------- | --------------------------------------- |
| Commands and streaming         | Yes         | Live output                             |
| Files                          | Yes         | `mode` is applied before the bytes land |
| Background processes           | Yes         |                                         |
| Preview URLs                   | Yes         | Send the returned `Cookie` header       |
| Pause and resume               | No          |                                         |
| Snapshots                      | No          |                                         |
| Machine size                   | Partial     | Presets only                            |
| Network policy                 | No          | Only `"all"`                            |
| Hard lifetime (`timeoutMs`)    | No          | Set the `ttlSeconds` auto-stop instead  |
| Idle timeout (`idleTimeoutMs`) | No          |                                         |
| Keys                           | Best-effort | Stored in the VM's display name         |
| Terminal                       | No          |                                         |

The [capability matrix](/docs/reference/capabilities) compares these with every other provider.

## Run commands

Await `box.run` for the result, or iterate it to stream output as it arrives.

```ts
import { createSandbox } from "@opencoredev/sandbox-sdk";
import { boat } from "@opencoredev/sandbox-sdk/boat";

await using box = await createSandbox(boat());
const result = await box.run("ls -la", { cwd: "/workspace", timeoutMs: 30_000 });
console.log(result.exitCode, result.stdout);

for await (const chunk of box.run("for i in 1 2 3; do echo $i; sleep 1; done")) {
  process.stdout.write(chunk.text);
}
```

A timeout or abort stops the command's process tree.

## Files

Relative paths resolve against `/workspace`.

```ts
import { createSandbox } from "@opencoredev/sandbox-sdk";
import { boat } from "@opencoredev/sandbox-sdk/boat";

await using box = await createSandbox(boat());
await box.files.write("notes/todo.txt", "ship the docs\n");
console.log(await box.files.read("notes/todo.txt"));
console.log(await box.files.list("notes"));

await box.files.write(".env", "API_TOKEN=...\n", { mode: 0o600 });
```

With `mode`, the file gets its permissions before any contents are written, so the contents are never readable by other users. Writes run as the sandbox user, so root-owned paths such as `/etc` fail. Write those with `box.run("sudo ...")`.

## Background processes and previews

No ports to declare. The preview needs the `Cookie` header in `preview.headers` on every request. Treat it as a secret.

```ts
import { createSandbox } from "@opencoredev/sandbox-sdk";
import { boat } from "@opencoredev/sandbox-sdk/boat";

await using box = await createSandbox(boat());
await box.start("npx serve -l 3000", { name: "web", ready: { port: 3000 } });
const preview = await box.preview(3000, { waitMs: 10_000 });
const response = await fetch(preview.url, { headers: preview.headers });
console.log(response.status);
```

## Pause, resume, and snapshots

This adapter has no pause, resume, or snapshots, and the methods don't exist on its sandbox type. Boat's own stop and snapshot endpoints are reachable through [`box.native`](#call-the-api-directly). Code that accepts any provider can check first:

```ts
import { createSandbox, hasCapability } from "@opencoredev/sandbox-sdk";
import { boat } from "@opencoredev/sandbox-sdk/boat";

await using box = await createSandbox(boat());
if (hasCapability(box, "pause")) await box.pause();
```

## Size, network, and lifetime

| Preset   | Boat type | CPUs | Memory    | Disk   |
| -------- | --------- | ---- | --------- | ------ |
| `small`  | `small`   | 2    | 4096 MiB  | 12 GB  |
| `medium` | `default` | 4    | 8192 MiB  | 50 GB  |
| `large`  | `large`   | 8    | 16384 MiB | 125 GB |

```ts
import { createSandbox } from "@opencoredev/sandbox-sdk";
import { boat } from "@opencoredev/sandbox-sdk/boat";

await using box = await createSandbox(boat(), { size: "large" });
```

Exact sizes, `network` other than `"all"`, `idleTimeoutMs`, and `timeoutMs` fail with `unsupported`.

By default Boat stops a VM an hour after it starts. Set `ttlSeconds` to change that, or `null` to turn auto-stop off. The timer restarts while Boat's own agent works in the VM, so it caps cost but is not a hard lifetime. A stopped VM has no SDK resume, so destroy it.

```ts
import { createSandbox } from "@opencoredev/sandbox-sdk";
import { boat } from "@opencoredev/sandbox-sdk/boat";

await using box = await createSandbox(
  boat({ ttlSeconds: 15 * 60, snapshots: false, monitoring: false }),
);
```

## Configuration

| Option             | Type                                          | Default                   | Env var         |
| ------------------ | --------------------------------------------- | ------------------------- | --------------- |
| `apiKey`           | `string`                                      |                           | `BOAT_API_KEY`  |
| `baseUrl`          | `string`                                      | `https://boat.dev/api/v1` | `BOAT_API_BASE` |
| `machine`          | `"small" \| "default" \| "large" \| "xlarge"` | `"default"`               |                 |
| `ttlSeconds`       | `number \| null`                              | `3600`                    |                 |
| `snapshots`        | `boolean`                                     | `true`                    |                 |
| `monitoring`       | `boolean`                                     | Account setting           |                 |
| `org`              | `string`                                      | Your active wallet        |                 |
| `requestTimeoutMs` | `number`                                      | `90_000`                  |                 |

A per-create `size` wins over `machine`. `xlarge` (16 CPUs, 32 GB) needs a $100+ plan. `ttlSeconds` takes 1 to 2,592,000 (30 days). `snapshots: false` means Boat never captures the disk, and a stop erases it. `monitoring: false` means Boat keeps no command output, process list, or metrics; live output still streams. `org` bills the VM to an organization, by id or name. `apiUrl` and `type` are deprecated aliases.

## Call the API directly

`box.native` calls any Boat endpoint with the adapter's base URL, key, retries, and error mapping. Paths are relative to `https://boat.dev/api/v1`, and `{sandboxId}` fills in from the sandbox. Boat has one plane, `"api"`. See the [native API reference](/docs/reference/native) for every option.

```ts
import { createSandbox } from "@opencoredev/sandbox-sdk";
import { boat } from "@opencoredev/sandbox-sdk/boat";

await using box = await createSandbox(boat());
await box.run("echo report > /tmp/report.txt");

const history = await box.native.request<{ commands: { command: string; status: string }[] }>(
  "GET",
  "/sandboxes/{sandboxId}/command-history",
  { query: { limit: 5 } },
);
console.log(history.commands.map((entry) => entry.status));

const file = await box.native.fetch("GET", "/sandboxes/{sandboxId}/artifacts", {
  query: { path: "/tmp/report.txt" },
});
console.log(await file.text());
```

`request` returns the JSON body and `fetch` the raw `Response`. A non-2xx status rejects with `SandboxError`.

For account calls with no sandbox, use the provider's `api`:

```ts
import { boat } from "@opencoredev/sandbox-sdk/boat";

const limits = await boat().api.request("GET", "/limits");
console.log(limits);
```

To use the official SDK instead, install `@boatdev/sdk` and pass its import to `sdk`. On a sandbox you get a `BoatApi` client set up with the same base URL and key, plus the sandbox's metadata. On `provider.api` you get the client alone.

```ts
import { createSandbox } from "@opencoredev/sandbox-sdk";
import { boat } from "@opencoredev/sandbox-sdk/boat";

await using box = await createSandbox(boat());
const { client, sandbox } = await box.native.sdk(() => import("@boatdev/sdk"));
console.log(sandbox.state);
const { snapshots } = await client.listSandboxSnapshots({ sandboxId: box.id });
console.log(snapshots.length);
```

In earlier betas, `box.native` was `{ sandbox, client }`. That pair now comes from `box.native.sdk(() => import("@boatdev/sdk"))`.

## Limitations

- Keys are best-effort, and the encoded key and labels must fit a 120-character name.
- No custom sizes, network policies, idle timers, hard lifetimes, pause, snapshots, or terminals.
- File writes run as the sandbox user, so root-owned paths fail.
- A native stop ends processes, and `/workspace` and `/tmp` records may not survive it.

## How it works

- Sandboxes are created with `noEnv: true`, so they get none of your account's saved secrets. `env` and `secrets` still apply.
- Boat has no labels, so the key and labels are compressed into the display name. Renaming the VM in Boat hides it from the SDK.
- Create retries up to three times with the same idempotency key. Commands are never replayed. Reads, file writes, and deletes retry on 429, 502, 503, 504, and dropped connections.
- Command output streams from Boat's NDJSON attach endpoint. Boat ends each connection after 10 minutes; the adapter reopens it from the last byte offsets, so no output is lost or repeated. A dropped connection or 60 seconds of silence is reopened the same way for up to 60 seconds.
- stdin and file contents never travel in command text, which Boat keeps in its history. Boat's file API takes at most 5 MiB per write and only paths under `/home/user` or `/tmp`. A write of up to 4 MiB under those paths, without `mode`, goes straight to it. Anything else, and all stdin, is uploaded in 4 MiB parts to a new `0700` directory under `/tmp`. A guest command then copies the file into place, or the shell opens the stdin file and deletes it before your command starts.
- Reads above 1 MiB use a signed download URL; smaller reads use guest commands.
- `destroy()` deletes the VM and its snapshots. Boat purges the data asynchronously.

## Next steps

- [Processes and previews](/docs/concepts/processes-and-previews)
- [Machine size and network](/docs/guides/machine-size-and-network)
