Skip to content

feat(sandbox-adapter): add boxd provider - #388

Draft
MichielMAnalytics wants to merge 1 commit into
rivet-dev:mainfrom
MichielMAnalytics:feat/boxd-provider
Draft

MichielMAnalytics wants to merge 1 commit into
rivet-dev:mainfrom
MichielMAnalytics:feat/boxd-provider

Conversation

@MichielMAnalytics

@MichielMAnalytics MichielMAnalytics commented Nov 6, 2026 •

Copy link
Copy Markdown

boxd × Rivet

Note

draft, please don't merge yet. i'm going through this by hand first and will mark it ready for review myself

Summary

I work on boxd. boxd runs full Linux microVMs (KVM) with a persistent disk that start in about a second, and can be hibernated, snapshotted and forked. This PR adds @rivet-dev/sandbox-adapter/boxd, shaped like e2b.ts and freestyle.ts. Each Actor gets one boxd machine. It is hibernated while the Actor sleeps and deleted when the Actor is destroyed. It was built with AI assistance and I tested it against production boxd.

sandbox: boxdProvider({
	create: (c) => ({ labels: { "rivet-actor-id": c.actorId } }),
}),

What changed:

  • packages/sandbox-adapter/src/boxd.ts: the provider.
  • packages/sandbox-adapter/package.json: the ./boxd export, and @boxd-sh/sdk (^0.3.0) as an optional peer dependency and a dev dependency.
  • packages/sandbox-adapter/tests/boxd.test.ts: 12 unit tests with a fake client, which always run, and 8 live tests shaped like docker.test.ts, which run only when BOXD_API_KEY is set.
  • Docs: a boxd section and providers row on the Sandboxes page, a row and a diagram node on the Sandbox Adapter page, a tab and a sentence in the quickstart, and the docs index card. The example is examples/docs/sandboxes/boxd.ts.
  • scripts/test-packed.mjs checks that dist/boxd.js is packed.
  • pnpm-lock.yaml: only the new @boxd-sh/sdk entries (15 lines added).

The repo has no changesets, so there is none here.

The earlier Sandbox Agent provider from this PR is kept on the branch feat/boxd-provider-sandbox-agent in my fork, in case you want it too.

How it maps onto SandboxProvider

SandboxProvider / Sandbox boxd
name boxd
cwd /home/boxd (option cwd), created on connect
create(c) machines.create(create). create can be a function of the Actor context. Returns the machine id.
connect(c, id) machines.get. A missing or destroyed machine returns undefined. A suspended, hibernated or stopped machine is resumed, woken or started, then waitUntilReady and fs.mkdir(cwd).
suspend(c, id) machines.hibernate. A machine that is gone or already stopped is left as it is.
destroy(c, id) machines.delete. A machine that is gone is fine.
exec machines.streamExec with stdin closed, running /bin/sh -c in cwd with env set for the whole command. stdout and stderr stream to onData as they arrive. A timeout or an abort closes the call, which ends the command's process group.
readFile, writeFile, mkdir, stat, readdir, exists machines.fs.read, write, mkdir, stat (isDir), list, exists

The client is created on first use, like daytonaProvider and freestyleProvider. new Boxd() reads BOXD_API_KEY.

Where boxd does not map cleanly

  1. Exit code after a signal. A command killed by a signal it gets from inside the machine (for example kill -9 $$) reports 128 + signal (137), not null. The Docker provider does the same. A timeout gives exitCode: null and timedOut: true, and an abort rejects with Error("aborted"), as the interface says.
  2. Output after a timeout. On a timeout the result keeps the output so far in stdout and stderr. E2B and Daytona return them empty. I think this is more useful, and it matches what onData already got.
  3. The shell is /bin/sh and the user is boxd. That matches the Docker and Freestyle providers' /bin/sh -c. The boxd user has passwordless sudo. Files written through the file API are owned by boxd.
  4. Sleep is hibernate, not pause. Hibernating writes memory to disk, so the machine costs no compute while the Actor sleeps, and files and background processes come back on the next connect. I chose it over pause (memory kept in RAM) because an Actor can sleep for a long time.
  5. The network is open by default. Unlike Freestyle, a boxd machine can reach the internet. The docs say how to limit it: pass a machine spec with an egress allowlist as create.spec.
  6. No API key on a boxd machine. When the worker itself runs on a boxd machine, new Boxd() without BOXD_API_KEY uses that machine's own sign-in. Everywhere else it throws AuthenticationError (missing_credentials). Pass client: new Boxd({ apiKey }) to be explicit.

Test plan

All live runs used production boxd (region fra), at most two machines at a time, each named, labelled and set to delete itself after an hour. Every machine was deleted at the end.

  • Unit and live tests: vitest run in packages/sandbox-adapter with BOXD_API_KEY set: 3 files, 49 passed (boxd 20: 12 unit and 8 live; plus Docker and sandboxEnv). Without a key: 40 passed, 8 skipped. The live boxd tests take about 6 s. They cover separate stdout and stderr, exit code, cwd and env with a quote in the value; output streaming before the command ends and an abort that kills it (sleep 300 gone from ps); a timeout that kills the command and keeps its output; a 1 ms timeout; file round trip, readdir, stat, exists, missing files throwing, file owner; host env not visible in the machine; suspend, reconnect with files intact, destroy, connect returning undefined after destroy, a second destroy; and a wrong API key throwing AuthenticationError with no machine created.
  • The CI packages job, locally: pnpm install --frozen-lockfile, pnpm build, pnpm check-types (including examples/docs), pnpm test (sandbox adapter 40 passed and 8 skipped, pi 118 of 118), pnpm check-boundaries, pnpm lint (no errors, the 5 existing warnings in packages/pi), npm pack --dry-run in both packages (dist/boxd.* included), and pnpm test:packed. All pass. The formatter step (lefthook run pre-commit --fail-on-changes on the changed files) passes.
  • A real agent loop, the way the docs set it up: I packed both packages, installed them into an empty project with npm next to @boxd-sh/sdk, rivetkit and @earendil-works/pi-durable, and ran examples/docs/sandboxes/boxd.ts with tsx. I only added a machine name, a label and deleteAfter to create, so I could find and delete the machine. The client was the quickstart client. With a real Anthropic key (anthropic/claude-opus-5-5, as in the example):
    1. A prompt asked the agent to run uname -a && whoami && pwd with bash, write notes/result.txt and read it back. It reported the boxd kernel, user boxd, /home/boxd and the machine's hostname from the file. 9.5 s.
    2. The Actor went to sleep on its own and the machine showed hibernated.
    3. A second prompt on the same Actor ran cat notes/result.txt; uptime. The machine woke and the file was there. 4.5 s.
    4. Destroying the Actor (through an extra action that calls c.destroy()) deleted the machine.
  • Other live checks: hibernate keeps a background process (sleep 1000 still running after reconnect, reconnect in 195 ms); suspend twice in a row and on an already hibernated machine; suspend on a stopped machine leaves it stopped; a machine spec with egress("example.com") passed as create.spec reached that host and could not reach github.com; no API key in a plain node:22 container throws AuthenticationError (missing_credentials) with no machine created.
  • Not done: I could not preview the docs on the website, so the extra diagram node on the Sandbox Adapter page (the providers box is 70 px taller and the two nodes on the left moved down to stay centred) is checked only against the Diagram component's source, which sizes the SVG from the nodes.

Links

@eersnington

Copy link
Copy Markdown
Member

ayyy!! 🔥

love to see it. from quick skim of things, looks like this is targeting sandbox-agent. I recommend targeting packages/sandbox-adapter (sorry, yeah the names are confusing) which fits into our recently launched Rivet Agents that's powered by Pi-Durable

It's a lot leaner to implement. I recommend pointing an agent to how we implemented e2b.ts and add a boxd.ts next to it (plus the ./boxd export and optional peer dep in package.json)

draft, please don't merge yet. i'm going through this by hand first and will mark it ready for review myself

no rush! tag me any time if you have questions or want a review :))

@eersnington

Copy link
Copy Markdown
Member

docs on sandbox-adapter https://rivet.dev/agents/docs/sandbox-adapter/

@mintlify

mintlify Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Preview approval required

This pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit 4fd17ef.

Approve preview

Add @rivet-dev/sandbox-adapter/boxd. Each Actor gets one boxd machine,
hibernated while the Actor sleeps and deleted when it is destroyed.
Commands stream through boxd's exec call, and a timeout or an abort
closes the call, which kills the command's process group. Files go
through the machine filesystem API.

@boxd-sh/sdk is an optional peer dependency. The Sandboxes, Sandbox
Adapter, and quickstart pages list boxd, with the example in
examples/docs/sandboxes/boxd.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MichielMAnalytics MichielMAnalytics changed the title feat(sandbox-agent): add boxd provider feat(sandbox-adapter): add boxd provider Nov 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants