Sandbox provider
Every chat runs in a sandbox. That’s where the repository is checked
out, the setup and run scripts execute, the agent’s commands run, and terminal
tabs connect. Sandboxes sit behind the SandboxProvider interface in
apps/app/lib/sandbox/, and SANDBOX_BACKEND selects the implementation:
Any other value fails at startup.
Vercel Sandbox
HostedVercel Sandbox authenticates with Vercel’s OIDC token, which is injected automatically on Vercel. For local development, link the project and pull a short-lived token:
vercel link
vercel env pull apps/app/.env.local # sets VERCEL_OIDC_TOKEN (~12 h)Each chat gets a fixed 4 vCPU, 8 GB sandbox. That’s room for the dev server, the agent’s edits and compiles, and the browsers behind shared frames. It doesn’t grow with the number of frames, because a new size only takes effect after a restart that loses every running process.
Shared frames
A hosted frame someone has made live (Go live in the frame’s toolbar) is
one Chrome page running in its chat’s sandbox, next to the dev server.
Every other frame is a preview in each viewer’s own browser and runs nothing in
the sandbox. A small service in the sandbox encodes every frame as
H.264 video at up to twice its size, 30 frames a second, and streams them
to the canvas over one WebSocket on the sandbox’s forwarded port 7682. The
canvas decodes it with WebCodecs. Viewers authenticate with a short-lived
token signed with a per-sandbox key derived from TERMINAL_AUTH_SECRET, and
only the person driving a frame gets a token that lets their input through.
When Claude drives a frame, the app connects to the same service and sends
each step with its own short-lived grant; the service applies it as real
input over the Chrome DevTools Protocol, and a person’s newer grant takes the
frame back at once.
A frame being watched costs about 0.75 of a vCPU while its page is still, and
about 1.6 while it animates. Only pictures that changed are encoded and sent.
A few seconds after its last viewer leaves, a frame pauses: encoding stops and
its page is frozen in its browser, about 500 MB of memory and a few percent of
a vCPU. It resumes in a moment, its state intact. To fit the 8 GB sandbox, a
chat keeps at most six frames’ browsers running; past that, the least
recently viewed paused frame closes its browser, keeping its cookies, storage
and profile, and reopens at its URL when viewed again. Sandboxes created before shared frames don’t forward the stream
port, so their frames can’t go live until the sandbox restarts. Set
SHARED_FRAMES=off to hide Go live and keep every hosted frame per-viewer.
Sandbox image
Sandboxes boot from a custom image: Vercel’s Ubuntu image
(vercel/sandbox/universal, with Node, pnpm, git and the coding CLIs) plus
Google Chrome, Xvfb, ffmpeg and xautomation for shared frames. Its Dockerfile is in
apps/app/vercel-sandbox-image/. The app creates sandboxes from the
screenplay-workspace repository in your app project’s
Vercel Container Registry, or
from VERCEL_SANDBOX_IMAGE if you set it. Push the image before your first deploy,
or starting a chat fails with an error naming the missing image.
The Vercel Sandbox image GitHub workflow builds and pushes it whenever the image
changes on main, and weekly for security updates. It signs in with GitHub
OIDC, so it needs no secret:
- In Vercel, add an OIDC policy that trusts your repository’s
.github/workflows/vercel-sandbox-image.yml. - In your GitHub repository, add the variables
VERCEL_TEAM_ID,VERCEL_TEAM_SLUGandSCREENPLAY_APP_VERCEL_PROJECT_SLUG(the app project). Add them as repository variables, not environment variables: the workflow doesn’t run in a GitHub environment. - Run the workflow once from the Actions tab.
The image builds on Vercel’s Ubuntu Sandbox image, which the workflow builds from Vercel’s own Dockerfiles because the managed one can’t be pulled as a base. To push it by hand instead, from the repository root of a linked project:
git clone --depth 1 https://github.com/vercel/sandbox vercel-sandbox
vercel vcr login docker
TAGS=vcr.vercel.com/<team>/<project>/screenplay-workspace:latest PUSH=true \
docker buildx bake -f apps/app/vercel-sandbox-image/docker-bake.hcl workspaceSandbox VMs hibernate. Idle sandboxes are snapshotted and reclaimed, and reopening a canvas restores them with the working tree intact. Restart sandbox uses the same snapshot. Once a snapshot has fully expired, the only way back is Recreate from scratch, which discards uncommitted work. That’s why the agent commits and pushes after every change.
Local worktrees
DesktopEach project resolves to one local clone: either your folder, or a managed clone of the URL you added. Each chat’s code is a git worktree of that clone, so there’s one object store per repository and one checkout per branch. Worktrees survive restarts, including uncommitted changes. Local sandboxes can’t hibernate, so Restart sandbox isn’t offered; Restart dev server and Recreate from scratch are.
Dev servers run under portless on a per-chat port. See Dev server & ports.
Adding a provider
Any backend that can run commands and read and write files in a Linux environment can be a provider: E2B, Modal, a Firecracker service, a Docker daemon.
-
Add a factory next to
vercel.ts, for exampleapps/app/lib/sandbox/e2b.ts:import "server-only" import type { SandboxProvider } from "./types" class E2BSandboxProvider implements SandboxProvider { async create(opts) { /* provision, return a SandboxInstance */ } async get(opts) { /* reconnect, return a SandboxInstance */ } } export function getE2BSandboxProvider(): SandboxProvider { return new E2BSandboxProvider() } -
Return it from
selectSandboxProvider()inapps/app/lib/sandbox/index.tsbehind a newSANDBOX_BACKENDvalue. -
Read your credentials (for example
E2B_API_KEY) inside the factory.
A SandboxInstance is deliberately small: name, worktreePath, homeDir,
domain(port), hostPort(port), runCommand, writeFiles,
readFileToBuffer, and delete(). See apps/app/lib/sandbox/types.ts. The
agent’s tools, the logs stream, and terminals are all written against this
interface.
Capabilities not every backend has are kept off the core interface.
Hibernation (snapshot(), extendTimeout()) is an optional extension,
detected with supportsHibernation(). A provider without it simply doesn’t
offer Restart sandbox. See apps/app/docs/adr/0003-honest-sandbox-provider-seam.md.