@render-lab/tasks-e2b
v0.1.2
Published
> ⚠️ **Experimental: proof of concept.** This package is part of the [Render Tasks](https://github.com/render-lab/render-tasks) POC and is published for testing only. It is not fully tested or production ready. Task names, inputs, outputs, and behavior ca
Readme
@render-lab/tasks-e2b
⚠️ Experimental: proof of concept. This package is part of the Render Tasks POC and is published for testing only. It is not fully tested or production ready. Task names, inputs, outputs, and behavior can change or break in any release. Pin exact versions and expect breaking changes.
Durable E2B sandbox tasks for Render Workflows.
This pack runs commands, executes code, and moves files inside an E2B sandbox as durable, JSON-only tasks. It follows a reconnect-per-operation model: no long-lived Sandbox handle is passed between tasks. Each task takes a sandboxId, reconnects through the @e2b/code-interpreter SDK, performs one bounded operation, and returns plain data. This keeps every attempt a single observable durable run and means only sandbox ids ever cross a task boundary — never a Sandbox, Execution, Buffer, Date, or vendor error (ADR-0003). The baked-in retry policy is the single source of truth; the E2B SDK does no automatic API retries.
import {
createSandbox,
getSandbox,
pauseSandbox,
resumeSandbox,
killSandbox,
runCommand,
runCode,
writeFiles,
readFile,
listFiles,
getSandboxLogs,
getSandboxMetrics,
} from "@render-lab/tasks-e2b";Tasks
| Task | Input | Output | Retry | Default output limits |
| --- | --- | --- | --- | --- |
| e2b.createSandbox | CreateSandboxInput | CreateSandboxResult | lifecycle (3× backoff) | — |
| e2b.getSandbox | SandboxIdInput | SandboxDTO | read (3× backoff) | — |
| e2b.pauseSandbox | PauseSandboxInput | SandboxLifecycleResult | lifecycle (3× backoff) | — |
| e2b.resumeSandbox | SandboxIdInput | SandboxLifecycleResult | lifecycle (3× backoff) | — |
| e2b.killSandbox | SandboxIdInput | SandboxLifecycleResult | lifecycle (3× backoff) | — |
| e2b.runCommand | RunCommandInput | RunCommandResult | none (0 retries) | 64 KiB per stdout/stderr |
| e2b.runCode | RunCodeInput | RunCodeResult | none (0 retries) | 64 KiB per stream, 256 KiB across text results, 512 KiB per image |
| e2b.writeFiles | WriteFilesInput | WriteFilesResult | none (0 retries) | 1 MiB aggregate (cap 2 MiB) |
| e2b.readFile | ReadFileInput | ReadFileResult | read (3× backoff) | 256 KiB (all-or-nothing) |
| e2b.listFiles | ListFilesInput | ListFilesResult | read (3× backoff) | 100 entries (cap 1,000) |
| e2b.getSandboxLogs | GetSandboxLogsInput | GetSandboxLogsResult | read (3× backoff) | 100 lines (cap 1,000), 256 KiB total |
| e2b.getSandboxMetrics | GetSandboxMetricsInput | GetSandboxMetricsResult | read (3× backoff) | 100 points (cap 1,000) |
The three retry classes live in src/retry.ts:
- lifecycle and read —
maxRetries: 3, 1s base, ×2 backoff (~1s, 2s, 4s). Reads are side-effect-free; lifecycle operations are convergent (see idempotency below). - none —
maxRetries: 0.runCommand,runCode, andwriteFileshave observable, non-convergent effects, so a hidden retry would silently run them twice. A failure surfaces as one attempt.
Idempotent, find-or-create lifecycle
createSandbox requires a non-empty idempotencyKey. The pack stores it under the reserved metadata key _render_tasks_idempotency_key on every sandbox it creates, and createSandbox looks up that key before creating. A retry after a lost response re-finds the existing sandbox (running or paused) and returns created: false instead of spawning a second. Callers cannot override the reserved key — a caller-supplied value under it is overwritten.
pauseSandbox, resumeSandbox, and killSandbox are convergent: an already-paused pause, or a not-found/already-killed kill, normalizes to changed: false rather than throwing, so they are safe under lifecycle retry.
Lifecycle example
const sandbox = await createSandbox({
idempotencyKey: `report-${jobId}`,
template: "code-interpreter-v1",
timeoutMs: 300_000,
});
try {
await writeFiles({
sandboxId: sandbox.sandboxId,
files: [{ path: "/tmp/input.json", encoding: "utf8", content: JSON.stringify(rows) }],
});
const run = await runCode({
sandboxId: sandbox.sandboxId,
language: "python",
code: "import json; print(len(json.load(open('/tmp/input.json'))))",
});
console.log(run.stdout.text);
const report = await readFile({ sandboxId: sandbox.sandboxId, path: "/tmp/report.md" });
handle(report.content);
} finally {
// The sandbox is a billed resource — always clean up explicitly.
await killSandbox({ sandboxId: sandbox.sandboxId });
}The E2B filesystem is ephemeral: it lives only as long as the sandbox. There are no webhooks in this version — lifecycle state is observed by polling getSandbox and cleaned up with killSandbox (ADR-0017).
Payload limits and the 4 MB cap
Every task result crosses a workflow boundary with a hard 4 MB argument/result limit. This pack targets at most ~1 MiB per result, leaving headroom. It uses the shared bounded-output helpers from @render-lab/tasks-core (ADR-0020):
- Text (
stdout,stderr, code results, log messages) is truncated on a UTF-8 code-point boundary and returnsbyteCount,returnedBytes,maxBytes, andtruncated. - Binary (code image artifacts) is all-or-nothing base64 — an image above its limit is rejected rather than silently returned partial.
- Collections (
listFiles,getSandboxLogs,getSandboxMetrics) reportreturned,available,limit, andtruncated. readFilechecks the file size first and rejects an oversize file before reading it, so a read never returns a corrupt partial (truncatedis alwaysfalse).
When a result would exceed its limit, the guidance is consistent: narrow the request, write the payload to the sandbox filesystem and return a path (retrievable via readFile), or copy it to an external signed destination and return a reference.
Environment contract
Credentials are read lazily at first use, never at import (ADR-0007). Constructing the default port touches no environment variable; a missing key fails on the first task call.
| Variable | Required | Purpose |
| --- | --- | --- |
| E2B_API_KEY | yes | Authenticates every sandbox operation. |
| E2B_API_URL | no | Overrides the API base (self-hosted / staging). Also used for the direct-fetch logs endpoint. |
Testing
pnpm -C packages/tasks-e2b build # tsc → dist
pnpm -C packages/tasks-e2b test # Tier 1: hermetic unit tests (no secrets, no network)
pnpm -C packages/tasks-e2b typecheck # tsc --noEmit
pnpm -C packages/tasks-e2b test:live # Tier 2: opt-in live tests (needs RUN_LIVE=1 + E2B_API_KEY)Tier 1 tests inject a fake port/SDK at the seam and never touch the network. The live suite (test/tasks.live.test.ts) is guarded by describe.skipIf(!process.env.RUN_LIVE), creates a real sandbox, round-trips a file, runs a command, reads metrics and logs, and always kills the sandbox in finally.
