Docs GitHub Get started

Sandbox providers

packages/sandbox gives the control plane one interface, SandboxProvider, with three implementations: docker, daytona and e2b. Pick one with LEMONLIME_SANDBOX_PROVIDER (see docs/self-hosting.md for the full list of settings).

import { createSandboxProvider } from "@lemonlime/sandbox";

// Node: pass process.env. Cloudflare Workers: pass the worker's `env` binding.
const provider = createSandboxProvider(process.env);

createSandboxProvider always takes the environment as an argument and throws straight away, naming the variable, if a required setting is missing.

Where it runs

The package only uses fetch and other web platform APIs, so the Daytona and E2B providers work both on Node 22 and on Cloudflare Workers (with nodejs_compat). There are no provider SDKs to install.

The Docker provider shells out to the docker CLI, so it needs Node and a Docker daemon on the same machine. It loads node:child_process only when it is first used, so importing the package is safe anywhere; on Workers its methods throw an error saying to use daytona or e2b instead.

With LEMONLIME_DOCKER_AGENT_USER=agent, the agent runs as its own user, apart from the thread secret. The container starts as root with only SETUID, SETGID and KILL added back (still no-new-privileges), and runs lemonlime-runner keeper --thread <id> --agent-user agent -- sh -c <runner>: the keeper keeps the secret, then starts the setup script and the runner as agent without it. The runner asks the keeper over a local socket to check tokens and to sign its own short runner tokens; the keeper never signs a token for the user's apps. As root, the keeper's memory and environment are out of the agent's reach, and so is the secret. Daytona (DAYTONA_AGENT_USER=agent: the sandbox is created with user: "root") and E2B (E2B_AGENT_USER=agent: envd starts the keeper as root) have the same switch and start the runner through the keeper the same way. Those two haven't been tried on a real sandbox yet, so try one thread before turning it on for everyone.

Lifecycle

docker daytona e2b
create docker run -d … sh -c <runner> create sandbox, wait until started, start the runner in a session create sandbox, start the runner through envd
pause docker stop stop the sandbox and wait until stopped pause (memory kept)
resume docker start (re-runs the runner) start, wait until started, start the runner again connect (resumes; the runner is still running)
destroy docker rm -f delete kill

Pausing or destroying a sandbox that is already paused or gone is not an error. If create fails after the provider made a sandbox (for example it never started, or the runner could not be launched), that sandbox is deleted again.

Daytona

Settings: DAYTONA_API_KEY (required), DAYTONA_API_URL (default https://app.daytona.io/api), and optionally DAYTONA_SNAPSHOT_SMALL, DAYTONA_SNAPSHOT_LARGE or DAYTONA_SNAPSHOT, and DAYTONA_AUTO_ARCHIVE_MINUTES.

Requests, all with Authorization: Bearer <DAYTONA_API_KEY>:

Step Request
create POST /sandbox with env, labels, public: true, autoStopInterval (60 min safety net), autoArchiveInterval (24 h, or DAYTONA_AUTO_ARCHIVE_MINUTES), and either buildInfo.dockerfileContent: "FROM <image>" + cpu/memory/disk or snapshot
wait GET /sandbox/{id} every second until started (2 minute limit; error or build_failed fail at once)
runner POST <toolbox>/process/session { sessionId: "lemonlime-runner" }, then POST <toolbox>/process/session/lemonlime-runner/exec { command, runAsync: true }
runner URL GET /sandbox/{id}/ports/{port}/preview-url
pause POST /sandbox/{id}/stop, then poll until stopped
resume POST /sandbox/{id}/start, poll, start the runner again
destroy DELETE /sandbox/{id}

<toolbox> is the sandbox's toolboxProxyUrl (or GET /sandbox/{id}/toolbox-proxy-url) followed by /<sandbox id>.

E2B

Settings: E2B_API_KEY (required), E2B_TEMPLATE_SMALL and E2B_TEMPLATE_LARGE (templates built from docker/sandbox.Dockerfile with the CPU and memory of each size).

Requests to https://api.e2b.app, all with X-API-Key: <E2B_API_KEY>:

Step Request
create POST /v2/sandboxes { templateID, metadata, envVars, timeout } (timeout: 1 hour safety net)
get GET /sandboxes/{id}
pause POST /sandboxes/{id}/pause {} (keeps memory)
resume POST /v2/sandboxes/{id}/connect { timeout }
destroy DELETE /sandboxes/{id}

The runner is started through envd, the process agent inside every E2B sandbox, at https://49983-<id>.e2b.app/process.Process/Start. This is a Connect server-streaming call using the JSON codec (Content-Type: application/connect+json, each message framed as one flag byte plus a 4-byte length). The request runs /bin/bash -l -c <runner command> with the runner's environment and the sandbox's envdAccessToken in X-Access-Token. The provider waits for the start event with the process id and then closes the stream; envd keeps the process running, the same as the E2B SDK's background commands.

The runner URL is https://<port>-<id>.e2b.app.

Sandboxes that can't reach the control plane

Some providers only let sandboxes reach a list of well-known hosts (GitHub, package registries, model providers) on their cheaper tiers, so the runner can't send heartbeats, history or git token requests to your control plane. Set LEMONLIME_RUNNER_RELAY=true on the control plane and runners start in relay mode instead: they keep those calls, and the control plane collects them from the runner every minute (through the runner's public port, with a token only the control plane and that sandbox can sign), makes them, and hands back the answers. A new sandbox also starts with a first, hour-long repository token, so its first clone doesn't wait.

On Node, POST /api/cron/sweep does the relaying too; on Cloudflare Workers the cron trigger does. Expect up to a minute's delay on heartbeats and on refreshing git tokens.

Checking a provider for real

With a provider's settings in the environment, this creates a small sandbox from the image, waits for the runner's /healthz, pauses, resumes, checks again and destroys it (it is destroyed even if a step fails):

LEMONLIME_SANDBOX_PROVIDER=daytona DAYTONA_API_KEY=... LEMONLIME_SANDBOX_IMAGE=ghcr.io/<owner>/lemonlime-sandbox:latest \
  pnpm --filter @lemonlime/sandbox live-check

Edit this page on GitHub