Boat
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, with Node.js, Git, and Docker installed.
Install
npm install @opencoredev/sandbox-sdkpnpm add @opencoredev/sandbox-sdkyarn add @opencoredev/sandbox-sdkbun add @opencoredev/sandbox-sdkThe 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
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 compares these with every other provider.
Run commands
Await box.run for the result, or iterate it to stream output as it arrives.
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.
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.
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. Code that accepts any provider can check first:
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 |
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.
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 for every option.
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:
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.
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
/workspaceand/tmprecords may not survive it.
How it works
- Sandboxes are created with
noEnv: true, so they get none of your account’s saved secrets.envandsecretsstill 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/useror/tmp. A write of up to 4 MiB under those paths, withoutmode, goes straight to it. Anything else, and all stdin, is uploaded in 4 MiB parts to a new0700directory 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.