API Reference

Complete contract for the e2a.bot platform — two API surfaces (sandboxes for compute primitives; sessions for the agent paradigm), tenant onboarding, template catalog, usage, WebSocket framing, desktop streaming, and sandbox lifecycle.

api.e2a.bot — control plane (sandboxes + sessions + tenants + usage)rt.e2a.bot — runtime WSS for sessionsapi.e2a.bot — legacy auth + billing

Getting Started

Shared vocabulary used across every endpoint and protocol on this page.

Bearer API key

Long-lived key for sandbox/session operations. Create via POST /v1/api-keys after OTP auth. Format: e2a_live_… Send as Authorization: Bearer e2a_live_…

Bearer JWT

Short-lived token for account operations (create API keys, billing). Obtained from POST /v1/auth/verify-otp. Use API key for most operations.

Templates

Pre-baked rootfs + kernel combinations. standard (minimal Linux), browser (Chromium headless), cua (Xvfb + fluxbox + Chromium + OmniParser for agent desktop control), canvas (HTML canvas rendering). Template selection is irreversible per sandbox.

Agents

Optional one-shot or interactive agent runtimes bundled into the rootfs. deictic (first-party Rust, needs explicit model), claude-code (Anthropic CLI, model must start with claude), codex (OpenAI CLI, model must start with gpt or o). Set llm_key when agent is present.

States

creating → configuring → booting → running → stopping → destroyed. running can branch to paused and back. Any state can emergency-transition to destroyed (reaper, manual DELETE, spot reclaim).

Hosts

Two public hostnames. api.e2a.bot — control plane (auth, API keys, sandboxes, sessions, secrets, billing). rt.e2a.bot — runtime WSS for agent sessions (wss://rt.e2a.bot/v1/sessions/{id}/ws). All endpoints use HTTPS; WebSocket uses WSS.

Secrets (BYO LLM keys)

Secret bundles store your LLM API keys in AWS Secrets Manager under your tenant namespace. Create bundles via POST /v1/tenants/me/secrets, then reference them by name in session create (secrets: ["my-bundle"]). Keys are injected into the sandbox at session start. Use LLM_API_KEY as the universal key name regardless of LLM vendor. Bundles support hot-replace: rotate keys via PUT without restarting running sessions — agents receive new values via WebSocket config_update frame.

Two-API distinction

e2a.bot exposes two API surfaces with distinct customer mental models. /v1/sandboxes/* is for compute primitives — generic isolated execution (exec, files, logs, batch jobs, CI/CD); no agent paradigm. /v1/sessions/* is for the agent paradigm — multi-agent conversations, WebSocket streaming over rt.e2a.bot, persistent state, resume after disconnect. Both use the same Bearer API key auth; only the use-case shape differs. Customers needing stateful agent conversations MUST use /v1/sessions/* — the /v1/sandboxes/{id}/ws WebSocket route was removed; agent WSS belongs exclusively to /v1/sessions/{id}/ws.

Sandboxes API (compute primitives)

api.e2a.bot/v1/sandboxes/* — generic isolated execution. Use this for one-shot batch jobs, CI/CD, scripts, file I/O, log tailing. Stateless; no agent paradigm. For multi-agent conversations + WSS + state + resume, use the Sessions API below. Every route authenticates with a Bearer API key. Note: /v1/sandboxes/{id}/ws was removed in 2026-04 — agent WS belongs exclusively to /v1/sessions/{id}/ws on rt.e2a.bot.

POST/v1/sandboxesBearer API key

Create a new sandbox. Optionally attaches an agent + task for one-shot execution, or leaves the sandbox idle for WebSocket-driven control.

Request

{
  "template": "standard",                  // required: "standard" | "browser" | "cua" | "canvas"
  "agent": "claude-code",                  // optional: "deictic" | "claude-code" | "codex"
  "llm_key": "sk-ant-...",                 // required iff agent is set
  "task": "summarize README.md",           // optional: one-shot prompt; absent ⇒ agent waits on WS
  "model": "claude-sonnet-4-5",            // required for deictic; prefix-validated for claude-code/codex
  "session_id": "abcd-1234",               // optional: codex resume uuid
  "wire_version": 2,                       // optional, default 2; set 1 only for legacy single-agent
  "idle_timeout_seconds": 60,              // optional, default 60
  "max_lifetime_seconds": 7200,            // optional, default + hard cap 7200
  "workspace_enabled": false,              // optional, default false
  "app_id": "my-app",                      // required iff workspace_enabled
  "capset_id": "default",                  // required iff workspace_enabled
  "app_json": {                            // optional; nested capability_sets schema (see notes)
    "name": "My App",
    "capability_sets": [{
      "name": "general",
      "agents": [{
        "name": "primary",
        "capability_name": "general",
        "capability_description": "general-purpose worker",
        "tools": ["read_file", "write_file", "shell_exec"]
      }]
    }]
  },
  "persistence_url": "https://chat.example.com/persist", // optional; HTTPS-only, public-IP only
  "persistence_auth_header": "Bearer sk_…",              // optional; verbatim Authorization header
  "env_vars": { "MY_VAR": "value" },       // optional; reserved keys rejected
  "metadata": { "tag": "prod" }            // optional free-form
}

Response

{
  "vm_id": "sbx_7f3k9m2x",
  "state": "running",
  "host_port": 30090,
  "endpoint": "http://worker-ip:9090"     // optional; orchestrator's self-reported node endpoint
}

Status codes

201Createdsandbox provisioned and reached Running
400Bad requestmissing required field, reserved env-var key, agent without llm_key, flat-shape app_json, multi-agent app_json on wire_version=1, or persistence_url failing the SSRF allowlist
401Unauthorizedmissing or invalid Bearer API key
409Conflictno capacity on any worker
501Not implementedunknown agent type or template
503Service unavailableno workers registered or reachable

Notes

  • Reserved env-var keys cannot be passed via env_vars or agent_env: AGENT_TYPE, LLM_KEY, LLM_MODEL, TASK, SESSION_ID, AGENT_CMD, AGENT_SESSION_MODE, E2A_PERSISTENCE_URL, E2A_PERSISTENCE_AUTH_HEADER, WORKSPACE_USER_ID, WORKSPACE_APP_ID, WORKSPACE_CAPSET_ID, WORKSPACE_MODE.
  • app_json schema (NESTED — replaces the legacy flat tools/system_prompt shape from PR #90): { name, capability_sets: [{ name, agents: [{ name, capability_name, capability_description, tools }] }] }. Multiple agents across one or more capability_sets enable planner-routed multi-agent execution on wire_version=2. Omitting app_json yields the e2a default — single agent, four-tool default (read_file, write_file, list_directory, shell_exec). Submitting the legacy flat shape (top-level tools or system_prompt) returns HTTP 400.
  • wire_version=1 is supported only for single-agent app_json (one agent across all capability_sets). Multi-agent + wire_version=1 returns HTTP 400 with "multi-agent requires wire_version=2". Default is 2.
  • persistence_url enables customer-supplied conversation persistence (Bring-Your-Own HTTP backend implementing the Persistence wire contract). MUST be HTTPS; SSRF allowlist rejects loopback, link-local, RFC1918, IPv6 ULA fc00::/7, cloud-metadata IPs, IPv4 0.0.0.0/8 — both literal IPs and hostnames that resolve to disallowed ranges. persistence_auth_header (any scheme: Bearer / Basic / ApiKey / custom) is sent verbatim as the HTTP Authorization header on every persistence call. See the BYO Persistence guide for a worked customer-backend example.
  • agent_cmd must be absolute and start with /home/user/ or /workspace/. Max 512 bytes.
  • Workspace mounting is performed at WS-attach time, not at VM boot. workspace_enabled + app_id + capset_id surface the config; the runtime mounts on session open.
GET/v1/sandboxesBearer API key

List all sandboxes owned by the authenticated user. Fans out across all worker nodes and merges results.

Response

// Full success — returns JSON array of VmResponse objects
[
  { "vm_id": "sbx_7f3k9m2x", "state": "running", "vcpu": 1, "cpu_fraction": 0.5, ... },
  ...
]

// Partial failure — wrapped object with partial flag
{
  "sandboxes": [ { ... } ],
  "partial": true
}

Status codes

200OKall workers responded (or degraded with partial:true)
401Unauthorizedmissing or invalid Bearer API key (any worker returning 401 short-circuits)
503Service unavailableno workers registered

Notes

  • partial: true appears when at least one worker failed or timed out and the merged list is incomplete. Clients should surface this to users rather than treating it as empty state.
  • Filtering happens on the worker side — the orchestrator only returns VMs whose metadata user_id matches the Bearer's user.
GET/v1/sandboxes/{id}Bearer API key

Get details for a specific sandbox, including agent status when an agent is attached.

Response

{
  "vm_id": "sbx_7f3k9m2x",
  "state": "running",
  "vcpu": 1,
  "cpu_fraction": 0.5,
  "memory_mib": 512,
  "rootfs": "standard.ext4",
  "metadata": { "tag": "prod", "user_id": "usr_abc" },
  "agent_exit_code": 0,                          // optional; present once agent exits
  "agent_status": "completed"                    // optional; "running" | "completed" | "failed"
}

Status codes

200OKsandbox found and caller owns it
401Unauthorizedmissing or invalid Bearer API key
404Not foundno such sandbox, or cross-tenant access attempt (no existence leak)

Notes

  • agent_exit_code and agent_status are omitted for non-agent sandboxes and for VMs not in the Running state.
  • agent_status transitions from running → completed (exit 0) or failed (non-zero exit). The sentinel is /tmp/agent.exit inside the VM.
DELETE/v1/sandboxes/{id}Bearer API key

Destroy a sandbox. Synchronous — returns once Firecracker, TAP, and port mappings are torn down. Workspace data (if enabled) is flushed to S3 before teardown.

Response

{
  "vm_id": "sbx_7f3k9m2x",
  "state": "destroyed"
}

Status codes

200OKsandbox destroyed
401Unauthorizedmissing or invalid Bearer API key
404Not foundno such sandbox or cross-tenant access attempt

Notes

  • Active WebSocket proxy tasks receive an explicit Close frame (code 1001, reason "sandbox destroyed") rather than waiting for TCP keepalive to observe the dead upstream.
POST/v1/sandboxes/{id}/execBearer API key

Execute a shell command inside the sandbox via SSH. Blocking — response returns once the command exits or the request context is cancelled.

Request

{
  "command": "echo hello && ls /workspace"
}

Response

{
  "output": "hello\ntotal 0\n"
}

Status codes

200OKcommand executed
400Bad requestmissing or empty command
401Unauthorizedmissing or invalid Bearer API key
404Not foundno such sandbox or cross-tenant access attempt
GET/v1/sandboxes/{id}/logsBearer API key

Retrieve the tail of the agent's stderr log. Primary way to collect one-shot task output once agent_status transitions to completed or failed.

Query parameters

NameTypeReqDefaultDescription
streamenumnostderrWhich log stream to return. Phase 2 only implements stderr; stdout and all return 501.
linesintegerno100Number of trailing lines to return. Mutually exclusive with bytes. Max 10000; values above are clamped.
bytesintegernoNumber of trailing bytes to return instead of lines. Mutually exclusive with lines. Max 1048576 (1 MiB); values above are clamped.

Response

{
  "content": "...",
  "truncated": false,
  "total_bytes": 12345
}

Status codes

200OKstream fetched
400Bad requestlines and bytes both set; zero value; non-integer; unknown stream value
401Unauthorizedmissing or invalid Bearer API key
404Not foundno such sandbox or cross-tenant access attempt
501Not implementedstream=stdout or stream=all (reserved for future)

Notes

  • truncated: true means the content returned is smaller than the underlying file (total_bytes). With bytes mode, the first partial line is also dropped so the body never starts mid-line.
  • Absolute body cap is 1 MiB regardless of lines or bytes — requests above that ceiling trim from the start at the first newline.
GET/v1/sandboxes/{id}/filesBearer API key

Download a file from inside the sandbox. Binary-safe (base64 round-trip internally).

Query parameters

NameTypeReqDefaultDescription
pathstringyesAbsolute path inside the VM. Must start with /; .. components are rejected.

Response

// Content-Type: application/octet-stream
// Body: raw file bytes

Status codes

200OKfile read
400Bad requestmissing path, relative path, or .. component present
401Unauthorizedmissing or invalid Bearer API key
404Not foundno such sandbox or file does not exist
POST/v1/sandboxes/{id}/filesBearer API key

Upload a file into the sandbox.

Query parameters

NameTypeReqDefaultDescription
pathstringyesAbsolute path inside the VM. Parent directory must exist.

Request

// Content-Type: application/octet-stream
// Body: raw file bytes

Response

{
  "status": "uploaded"
}

Status codes

200OKfile written
400Bad requestinvalid path or missing body
401Unauthorizedmissing or invalid Bearer API key
404Not foundno such sandbox
POST/v1/sandboxes/{id}/cancelBearer API key

Broadcast a cancel signal to all active WebSocket subscribers for this sandbox. Does NOT destroy the VM — use DELETE for that or wait for TTL.

Request

// Body is optional; defaults to { "reason": "user_request" } when empty.
{
  "reason": "user_request"                 // "credits_exhausted" | "user_request" | "admin_action" | "timeout"
}

Response

{
  "vm_id": "sbx_7f3k9m2x",
  "cancelled": true,                       // true if any WS subscriber received the broadcast
  "reason": "user_request"
}

Status codes

200OKbroadcast attempted (cancelled may be false if no WS subscribers)
401Unauthorizedmissing or invalid Bearer API key
404Not foundno such sandbox or cross-tenant access attempt
409Conflictsandbox is not in the Running state

Notes

  • The cancel broadcast is a cooperative signal — the agent running in the sandbox decides how to react. Deidict + claude-code + codex handle it by wrapping up and writing to /tmp/result.txt before exit.
  • To forcibly destroy the VM, use DELETE /v1/sandboxes/{id}.

Sessions API (agent paradigm)

api.e2a.bot/v1/sessions/* (control plane) + wss://rt.e2a.bot/v1/sessions/{id}/ws (runtime WSS). Use this for multi-agent conversations, persistent state across reconnects, and resume after disconnect. Bearer API key authenticates create/resume; the session JWT returned at create time authenticates the WSS upgrade.

POST/v1/sessionsBearer API key

Create a new agent session. Spins a sandbox bound to a freshly-minted chat_session_id and returns a session JWT plus a connect_url for the WSS upgrade. State writes during the session persist; reconnects + resume are supported.

Request

{
  "agent": { "kind": "deictic", "version": "b4232d5" },
  "template": "cua",                       // required: "cua" | "browser" | "standard"
  "llm": {
    "vendor": "anthropic",                 // required: "anthropic" | "openai" | "google" | "deepseek"
    "model": "claude-sonnet-4-5",          // optional: override default model for vendor
    "base_url": "https://proxy.example.com/v1"  // optional: custom LLM endpoint (HTTPS only)
  },
  "secrets": ["my-llm-keys"],              // optional: secret bundle names to inject (see Secrets API)
  "app": { "id": "my-app", "set_id": "default", "capability_sets": {} },
  "user_id": "user_uuid_123",              // required; flat tenant model = your tenant_id
  "ttl_seconds": 3600                      // optional, default 3600, hard cap 7200
}

Response

{
  "chat_session_id": "ses_abc123",
  "jwt": "eyJ...",                         // session JWT — exp ≈ ttl_seconds; HS256
  "connect_url": "wss://rt.e2a.bot/v1/sessions/ses_abc123/ws",
  "expires_at": "2026-04-28T13:00:00Z"
}

Status codes

201Createdsession bound + sandbox running + JWT minted
400Bad requestunknown template, unknown agent.kind, missing user_id, invalid llm.model regex, llm.base_url fails SSRF allowlist, or secret bundle key collision
401Unauthorizedmissing or invalid Bearer API key
404Not foundreferenced secret bundle does not exist
503Service unavailableno orchestrator workers reachable

Notes

  • Templates accepted: standard | browser | cua. Discover via GET /v1/templates.
  • agent.kind accepted: deictic (only at MVP). codex + claude-code are post-MVP additions.
  • The session JWT in connect_url is short-lived (1h default); the WSS handshake validates it server-side. Browser clients can pass it via ?token= query (Authorization header is not browser-set on WS).
  • secrets: array of bundle names created via POST /v1/tenants/me/secrets. At most 10 bundles per session. Keys are merged; collision across bundles returns 400. LLM_API_KEY is the universal key name for LLM credentials (vendor-agnostic).
  • llm.model regex: ^[a-zA-Z0-9._-]{1,128}$. llm.base_url must be HTTPS and pass the SSRF allowlist (no loopback, RFC1918, link-local, cloud-metadata IPs).
POST/v1/sessions/resumeBearer API key

Resume an existing chat_session_id on a fresh sandbox. The original VM may be terminated; resume re-spins a new VM bound to the same chat_session_id, restores persisted state, and returns a new session JWT + connect_url.

Request

{
  "chat_session_id": "ses_abc123",
  "ttl_seconds": 3600
}

Response

{
  "chat_session_id": "ses_abc123",
  "jwt": "eyJ...",                         // NEW JWT — fresh exp
  "connect_url": "wss://rt.e2a.bot/v1/sessions/ses_abc123/ws",
  "expires_at": "2026-04-28T13:00:00Z",
  "resumed_from": {
    "previous_status": "completed"         // pre-resume state.status; "unknown" when state predates the field
  }
}

Status codes

200OKnew sandbox spun + state-storage TTL intact
401Unauthorizedmissing or invalid Bearer API key
403Forbiddencross-tenant resume — chat_session_id exists but is owned by a different tenant; audit log carries cross_tenant_resume_denied with owner_tenant_id
404Not foundchat_session_id never existed under any tenant
502Bad gatewaysecret_bundle_not_found_at_resume — a secret bundle referenced by the original session was deleted between create and resume

Notes

  • Idempotent for ~30s: rapid double-resume calls within the lock-cache window return the SAME jwt + connect_url payload (Redis lock per chat_session_id).
  • Cross-tenant probe distinguishes 403 (exists, you don't own it) from 404 (never existed) — better audit signal for enumeration attacks.
  • Reconciler-driven retry vs customer-driven resume share the claim_retry atomic primitive (canary scope) to prevent double-spawn under contention.
  • Resume recovers the original llm.model, llm.base_url, and secrets references from state storage — no need to re-specify them.
WS/v1/sessions/{id}/wsSession JWT (Authorization: Bearer or ?token= query)

Runtime WSS endpoint on rt.e2a.bot. Upgrade with the JWT returned by POST /v1/sessions or POST /v1/sessions/resume. Bidirectional frames stream agent control, stdio, and (for cua templates) desktop screenshots. Browser clients should use ?token=<jwt> since browsers can't set Authorization on WS upgrades.

Status codes

101Switching protocolsupgrade accepted; JWT validates + chat_session_id matches URL + tenant matches
401Unauthorizedmissing/invalid/expired JWT, OR session unknown (anti-oracle — does not leak existence)
403ForbiddenJWT's chat_session_id ≠ URL path id, OR JWT's tenant_id ≠ session's owner tenant_id

Notes

  • Full frame shape + close codes documented in the Protocols section's WebSocket entry.
  • Single connection per chat_session_id at a time; second concurrent upgrade closes the first with code 1011.

Protocols

Non-REST interactions — WebSocket framing for the agent paradigm, CUA desktop streaming, and the one-shot agent task workflow.

WebSocket

GET wss://rt.e2a.bot/v1/sessions/{id}/ws

Real-time bidirectional channel for the agent paradigm. Text frames carry JSON control messages; binary frames carry desktop screenshots (CUA only). e2a-api validates the session JWT (issued at POST /v1/sessions or /v1/sessions/resume) + cross-tenant tenant_id check + path-binding check, then forwards the upgrade to the worker over an internal-secret-gated channel. Customers pass the JWT via Authorization: Bearer header OR ?token= query (browsers can't set Authorization on WS upgrades).

Session JWT claims

ClaimTypeReqDefaultDescription
user_idstringyesFrom the Bearer API key; baked into the session JWT.
app_idstringyese2a-sandboxRuntime-required identity field.
set_idstringnodefaultRuntime capability-set scope.
chat_session_idstring (UUID)yesFresh per-upgrade UUID. Used by the runtime to partition one WS attach from another.
expunix secondsyesnow + 60sSession JWT TTL. Short-lived by design — the orchestrator re-mints on each upgrade.
cua_enabledbooleannofalseSignals the runtime to activate CUA tool context (screenshot, mouse/keyboard).

Frames

text (JSON)· bidirectional

Agent control + stdio stream. Schema is defined by the agent running inside the VM (deictic, claude-code, or codex). The orchestrator does not parse them — it passes text frames through and optionally intercepts for metering.

{ "type": "exec", "command": "ls -la" }           // client → server
{ "type": "stdout", "data": "total 68\n" }         // server → client
{ "type": "exit", "exit_code": 0 }                  // server → client
binary· server→client

Raw image bytes (PNG/JPEG). Only emitted when cua_enabled is true. No wrapper frame — the payload is the image. Clients render directly.

ping· server→client

Server-originated keepalive every 30 seconds to prevent idle NAT / load-balancer timeouts. Clients should reply with pong; most WebSocket libraries do this automatically.

text (system event)· server→client

Pre-termination warning, broadcast once ~10 seconds before max_lifetime_seconds expiry when at least one WS client is attached. Also emitted when POST /v1/sandboxes/{id}/cancel is called.

{ "type": "system", "event": "cancel", "reason": "timeout" }
text (system event)· server→client

Hot-replace notification. Emitted when a secret bundle referenced by this session is rotated via PUT /v1/tenants/me/secrets/{name}. The agent runtime receives the updated key values and swaps them in-place without session restart. keys lists the updated key names.

{ "type": "system", "event": "config_update", "keys": ["LLM_API_KEY"] }
close· server→client

Orderly disconnect. 1001 on SIGTERM or explicit DELETE of the sandbox; 1011 when the upstream runtime is unreachable at upgrade time.

code=1001, reason="server shutting down"
code=1001, reason="sandbox destroyed"
code=1011, reason="upstream unavailable"

Notes

  • The session JWT is not exposed to the client — the orchestrator mints it internally and forwards it to the runtime via the upstream upgrade URL.
  • Text frames sent by the agent may be intercepted by the orchestrator's metering path (runtime_event messages are counted toward billing). Binary frames are never parsed.

Desktop streaming (CUA)

CUA sessions (template: "cua") boot an Xvfb virtual display with fluxbox + Chromium + a local OmniParser UI-detection server. When an agent calls the screenshot tool, the runtime captures the framebuffer and emits the image as a binary WebSocket frame. Mouse and keyboard control flow through text tool-call frames in the opposite direction.

Flow

  1. Create session with template: "cua" via POST /v1/sessions (or set env_vars.CUA_ENABLED="true" for custom setups).
  2. Open WebSocket to wss://rt.e2a.bot/v1/sessions/{chat_session_id}/ws using the JWT returned at create time. The session JWT carries cua_enabled: true, which unlocks the CUA tool context on the runtime side.
  3. Agent calls tools: screenshot, locate_element (OmniParser YOLO+OCR), mouse_click, mouse_move, keyboard_type, press_key, scroll, shell_exec.
  4. Each screenshot response is delivered to the client as a binary WebSocket frame (raw PNG).
  5. Mouse and keyboard events from the client (or from the agent reasoning loop) are sent as text frames and replayed into the VM via xdotool/enigo.

Notes

  • Default display geometry is 1920x1080x24. OmniParser runs locally at http://127.0.0.1:8765 inside the VM and serves YOLO icon detection + Florence-2 captioning.
  • The runtime does not re-compress screenshots — image quality and encoding are whatever the agent's capture path produces (PNG by default).

One-shot agent task

For fire-and-forget agent runs (scrape, summarize, generate), pass agent + task + llm_key at create time and poll until complete. The sandbox self-destructs via idle timeout or max lifetime after the agent exits; no explicit DELETE needed unless you want to free resources sooner.

Flow

  1. Submit POST /v1/sandboxes with agent, task, llm_key, model, and (optionally) session_id for codex resume.
  2. Orchestrator injects AGENT_TYPE, TASK, LLM_KEY, LLM_MODEL, SESSION_ID into the VM's /app/config/env.json.
  3. Inside the VM, bootstrap-agent.sh dispatches based on AGENT_TYPE — runs /usr/local/bin/agent (deictic), claude --print … (claude-code), or codex exec … (codex).
  4. Agent stdout is tee'd to /tmp/result.txt. Agent exit code is written to /tmp/agent.exit.
  5. Poll GET /v1/sandboxes/{id} — agent_status transitions running → completed | failed, agent_exit_code becomes populated.
  6. Retrieve output via GET /v1/sandboxes/{id}/logs (tails /tmp/result.txt), or via POST /v1/sandboxes/{id}/exec with cat /tmp/result.txt for a single synchronous read.

Notes

  • Known agent types: deictic, claude-code, codex.
  • claude-code and codex are pre-baked npm-installed CLIs. ANTHROPIC_BASE_URL and OPENAI_BASE_URL are allow-listed for proxy scenarios (e.g., enterprise gateways).
  • The same flow can be made interactive instead of one-shot: omit task at create time and drive the agent via WebSocket.

Sandbox Lifecycle

States, TTL rules, and cancel semantics that govern every sandbox.

States

creatingResource allocation, port + IP assignment. VM has no compute yet.
configuringRootfs copy, kernel + drive attach, network config prepared.
bootingFirecracker process spawned; init + systemd coming up.
runningReady. SSH + WebSocket accept connections; agent (if any) running.
pausedFirecracker Ctrl-Alt-Del / pause equivalent. running ⇄ paused is resumable.
stoppingGraceful teardown in progress. Workspace flush, process cleanup.
destroyedTerminal. Resources released. Sandbox record remains briefly for the List endpoint.

Valid transitions: creating → configuring → booting → running; running ⇄ paused; running → stopping → destroyed; any state can emergency-transition to destroyed.

TTL rules

FieldTypeDefaultBehavior
idle_timeout_secondsu6460When active_ws_connections == 0 for this many seconds, the reaper destroys the sandbox. Timer starts only after the VM reaches Running (boot time does not count).
max_lifetime_secondsu647200 (hard cap)Regardless of WS activity, the sandbox is destroyed this many seconds after creation. Hard-capped at 7200 — values above are clamped. At max_lifetime - 10s, if any WS client is attached, a one-time pre-termination warning is broadcast.

The reaper evaluates rules every 10 seconds. Evaluation order: hard max lifetime → pre-expiry warning window → idle timeout.

Cancel vs destroy

  • POST /v1/sandboxes/{id}/cancel — broadcasts a cooperative {type:"system", event:"cancel", reason:"…"} message over the WebSocket. The agent decides how to react. Does not destroy the VM. 409 if the sandbox is not running.
  • DELETE /v1/sandboxes/{id} — forcibly tears down Firecracker, TAP, and port mappings. Active WS proxies receive a 1001 close with reason "sandbox destroyed".
  • Idle or max-lifetime expiry — automatic destroy by the reaper. Active WS subscribers see a {event:"cancel", reason:"timeout"} warning ~10 seconds before the destroy.

Platform API

api.e2a.bot — auth (OTP), API keys, dashboard, billing, and workspaces. Every route after verify-otp authenticates with a Bearer JWT.

Tenants (P6 — modern signup)

Self-serve tenant onboarding for individual customers. Email + OTP flow issues a tenant_id + initial Bearer API key in one shot. The api_key is shown only at verify-time; subsequent reads return only the prefix. Distinct from the legacy /v1/auth/* group below — /v1/tenants is the path forward; /v1/auth/* remains for existing users on api.e2a.bot.

POST/v1/tenantsNone

Begin signup for a new tenant. Sends a one-time passcode (OTP) to the email. Per memory project_e2a_bot_sni_dpi: customer-facing api.e2a.bot is the canonical host for /v1/tenants; do not use api.e2a.bot for new accounts.

Request

{ "email": "you@example.com" }

Response

{ "message": "OTP sent" }

Status codes

200OKOTP dispatched via SES
400Bad requestmissing or malformed email
429Too many requestsOTP rate-limit per email (3/hour) OR per-IP rate-limit on the auth-rate-limited prefix
500InternalSES send failed (e.g., domain not verified for a real-domain test)

Notes

  • Test mode: emails matching *@e2a-qa.test domain skip SES delivery + return verify_code in the response body for QA Playwright runs (no real inbox needed). Production-domain emails always go through SES.
POST/v1/tenants/verifyNone

Verify the OTP and provision the tenant. Returns tenant_id + initial Bearer API key in one response. The api_key is shown ONCE — store it.

Request

{ "email": "you@example.com", "code": "123456" }

Response

{
  "tenant_id": "usr_eab8d564...",          // cf. tnt_xxx is the canonical contract shape; usr_ prefix tracked as a separate cleanup ticket
  "key_id": "key_e6ba43557ec8628c",
  "prefix": "e2a_92ca",
  "api_key": "e2a_92caf7cf...64-hex-chars",  // SHOWN ONCE — store at this point
  "plan_tier": "free",
  "created_at": "2026-04-28T09:40:32Z",
  "token": "eyJ..."                        // JWT — short-lived; used for /v1/tenants/me reads
}

Status codes

200OKOTP valid + tenant + key created
401UnauthorizedOTP invalid / expired / wrong code

Notes

  • Schema: api_key format is e2a_<64-hex-chars> (NOT base64). Stored server-side as bcrypt(KeyHash) + sha256(KeySHA256) two-column non-reversible pair.
GET/v1/tenants/meBearer API key

Get the caller's tenant info.

Response

{
  "tenant_id": "usr_eab8d564...",
  "email": "you@example.com",
  "created_at": "2026-04-28T09:40:32Z",
  "plan": "free"
}

Status codes

200OKkey valid + tenant resolved
401Unauthorizedmissing or invalid Bearer API key
POST/v1/tenants/me/api-keysBearer API key

Issue an additional API key under the same tenant. The api_key field is returned ONCE — store it now.

Response

{
  "key_id": "key_<uuid>",
  "prefix": "e2a_xxxx",
  "api_key": "e2a_<64-hex>",                // SHOWN ONCE
  "plan_tier": "free",
  "created_at": "..."
}

Status codes

200OKkey issued
401Unauthorizedmissing or invalid Bearer API key
GET/v1/tenants/me/api-keysBearer API key

List all API keys for the tenant. NEVER returns api_key values — only key_id, prefix, plan_tier, revoked, created_at.

Response

{
  "keys": [
    {
      "key_id": "key_abc",
      "prefix": "e2a_92ca",
      "plan_tier": "free",
      "revoked": false,
      "created_at": "..."
    }
  ]
}

Status codes

200OKlist returned (may be empty)
401Unauthorizedmissing or invalid Bearer API key
DELETE/v1/tenants/me/api-keys/{key_id}Bearer API key

Revoke an API key. Subsequent requests using the revoked key return 401. The list endpoint continues to show revoked keys (with revoked: true) for audit.

Status codes

204No contentkey revoked
401Unauthorizedmissing or invalid Bearer API key
404Not foundkey_id does not belong to caller's tenant

Templates catalog (P6)

Discover the (app_id, capability_set) combinations valid for POST /v1/sessions. At MVP only deictic is in the catalog; codex + claude-code are post-MVP additions.

GET/v1/templatesBearer API key

Return the agent template catalog. Catalog cross-validates against POST /v1/sessions accept-list — every catalog entry MUST be acceptable to /v1/sessions (TC-0009 contract).

Response

{
  "templates": [
    {
      "app_id": "deictic",
      "name": "Deidict (default agent)",
      "description": "General-purpose multi-agent orchestration; recommended default.",
      "capability_sets": [
        { "set_id": "default", "name": "Default", "tools": ["file_read", "file_write", "shell", "search"] }
      ]
    }
  ]
}

Status codes

200OKcatalog returned
401Unauthorizedmissing or invalid Bearer API key
429Too many requeststenant rate-limit hit (TC-0024)
GET/v1/templates/{app_id}Bearer API key

Detail for a specific template by app_id. Returns the same shape as the catalog list, scoped to one entry.

Status codes

200OKtemplate found
401Unauthorizedmissing or invalid Bearer API key
404Not foundunknown app_id; audit emits template_not_found

Usage (P6 — customer-facing billing visibility)

Read your own current-period and historical usage. Wraps the internal billing accumulator with a customer-safe shape. M2-trimmed: cpu_seconds + sandbox_count + credits_used_usd; memory_gib_seconds + disk_gib_seconds + estimated_cost_usd are tracked post-MVP.

GET/v1/usageBearer API key

Current-period (this month) usage + credit balance for the caller's tenant. Per-tenant scope only — no cross-tenant leakage. Eventual-consistency window: ≤5min between sandbox activity + reflected here.

Response

{
  "tenant_id": "usr_abc",
  "period": "2026-04-01..2026-04-30",
  "infra_usage": {
    "cpu_seconds": 12345,
    "sandbox_count": 5,
    "credits_used_usd": 2.34
  },
  "credits": {
    "balance_usd": 87.66,
    "auto_topup_enabled": false
  }
}

Status codes

200OKusage computed
401Unauthorizedmissing or invalid Bearer API key

Notes

  • Direct customers (flat tenant model) do NOT see a subtenant_id field — it's intentionally absent for direct-customer paths per the audit-shape contract.
  • Customer-side billing is infra-only (CPU/RAM/Disk). LLM-token usage is intercepted as runtime_events but NOT charged to the tenant — customers BYO LLM key, so LLM cost is on their LLM provider's bill, not e2a.
GET/v1/usage/historyBearer API key

Historical usage windows. Query params from + to (ISO-8601 dates) bracket the window; default last 30 days.

Query parameters

NameTypeReqDefaultDescription
fromISO-8601 datenoStart of window (inclusive). Default: 30 days ago.
toISO-8601 datenoEnd of window (exclusive). Default: today.

Response

{
  "entries": [
    {
      "period_start": "2026-04-01",
      "period_end": "2026-04-01",
      "infra_usage": { "cpu_seconds": 12345, "sandbox_count": 0, "credits_used_usd": 0 },
      "session_count": 0
    }
  ]
}

Status codes

200OKwindows returned (may be empty array)
400Bad requestinvalid from/to date format or to <= from
401Unauthorizedmissing or invalid Bearer API key

Auth (legacy — api.e2a.bot)

Email + OTP flow on the legacy api.e2a.bot host. Exchange the OTP for a short-lived JWT used by every other api.e2a.bot route. New customers should use the modern /v1/tenants flow on api.e2a.bot above.

POST/v1/auth/registerNone

Register a new account. Sends a one-time passcode (OTP) to the email.

Request

{ "email": "you@example.com" }

Response

{ "message": "OTP sent" }

Status codes

201CreatedOTP dispatched
400Bad requestmissing or malformed email
429Too many requestsrate limit per email/IP
POST/v1/auth/loginNone

Start login for an existing account. Sends a one-time passcode (OTP) to the email.

Request

{ "email": "you@example.com" }

Response

{ "message": "OTP sent" }

Status codes

201CreatedOTP dispatched
400Bad requestmissing or malformed email
429Too many requestsrate limit per email/IP
POST/v1/auth/verify-otpNone

Verify the OTP sent by register/login. Returns a JWT used for all other api.e2a.bot routes.

Request

{ "email": "you@example.com", "code": "123456" }

Response

{
  "token": "eyJ...",                    // JWT — short-lived
  "user_id": "usr_abc",
  "email": "you@example.com"
}

Status codes

200OKOTP valid, token issued
401UnauthorizedOTP invalid or expired

API Keys

Create, list, and revoke Bearer API keys. Secrets are shown only once at creation.

POST/v1/api-keysBearer JWT

Create a new API key. The full secret is returned once; subsequent reads only return the prefix.

Response

{
  "key_id": "key_abc",
  "key": "e2a_live_...",                // full secret — only returned here, once
  "prefix": "e2a_live_abc"
}

Status codes

201Createdkey issued
GET/v1/api-keysBearer JWT

List all API keys for the authenticated user. Secrets are not returned.

Response

{
  "api_keys": [
    {
      "key_id": "key_abc",
      "prefix": "e2a_live_abc",
      "revoked": false,
      "created_at": "2026-04-18T12:34:56Z",
      "plan_tier": "free"
    }
  ]
}

Status codes

200OKlist returned
DELETE/v1/api-keys/{id}Bearer JWT

Revoke an API key. Irreversible; revoked keys stop authenticating immediately.

Response

{ "status": "revoked" }

Status codes

200OKkey revoked
404Not foundno such key_id for this user

Secrets

Manage secret bundles for BYO LLM keys. Bundle keys are injected into sessions at create time and can be hot-replaced without restart.

POST/v1/tenants/me/secretsBearer API key

Create a new secret bundle. Values are stored in AWS Secrets Manager under your tenant namespace.

Request

{
  "name": "my-llm-keys",                   // regex: ^[a-z0-9][a-z0-9-]{0,62}$
  "values": {
    "LLM_API_KEY": "sk-ant-..."            // LLM_API_KEY is the universal key name
  }
}

Response

{
  "name": "my-llm-keys",
  "key_names": ["LLM_API_KEY"],            // values are NEVER returned
  "created_at": "2026-05-01T12:00:00Z"
}

Status codes

201Createdbundle stored
400Bad requestinvalid name regex, reserved key name, value exceeds 64KB, or bundle exceeds 50 keys
409Conflictbundle with this name already exists
429Too many requeststenant quota exceeded (50 bundles)

Notes

  • Reserved key names cannot be used: AGENT_TYPE, LLM_KEY, LLM_MODEL, TASK, SESSION_ID, AGENT_CMD, AGENT_SESSION_MODE, E2A_PERSISTENCE_URL, E2A_PERSISTENCE_AUTH_HEADER, LLM_API_KEY (when used directly — only via bundle injection).
  • Max 50 keys per bundle, 64KB per value, 50 bundles per tenant.
GET/v1/tenants/me/secretsBearer API key

List all secret bundles. Values are NEVER returned.

Response

{
  "bundles": [
    {
      "name": "my-llm-keys",
      "key_names": ["LLM_API_KEY"],
      "created_at": "2026-05-01T12:00:00Z",
      "last_rotated_at": "2026-05-01T14:00:00Z"
    }
  ]
}

Status codes

200OKlist returned
GET/v1/tenants/me/secrets/{name}Bearer API key

Get metadata for a specific bundle. Values are NEVER returned.

Response

{
  "name": "my-llm-keys",
  "key_names": ["LLM_API_KEY"],
  "created_at": "2026-05-01T12:00:00Z",
  "last_rotated_at": "2026-05-01T14:00:00Z"
}

Status codes

200OKbundle found
404Not foundno such bundle
PUT/v1/tenants/me/secrets/{name}Bearer API key

Rotate (replace) all values in an existing bundle. Triggers hot-replace to all running sessions using this bundle via WebSocket config_update frame.

Request

{
  "values": {
    "LLM_API_KEY": "sk-ant-new-key..."     // new value
  }
}

Response

{
  "name": "my-llm-keys",
  "key_names": ["LLM_API_KEY"],
  "last_rotated_at": "2026-05-01T15:00:00Z"
}

Status codes

200OKbundle rotated + broadcast sent
400Bad requestinvalid key name, reserved key, value too large
404Not foundno such bundle

Notes

  • Running sessions receive the new values in-place via system/config_update WebSocket frame. No session restart required.
  • Key set can change during rotation (add/remove keys). Sessions re-resolve the full bundle.
DELETE/v1/tenants/me/secrets/{name}Bearer API key

Delete a secret bundle. Running sessions continue with their current values; future resume calls return 502 secret_bundle_not_found_at_resume.

Response

{ "status": "deleted" }

Status codes

200OKbundle deleted
404Not foundno such bundle

Notes

  • Running sessions are NOT terminated — they keep their resolved values until TTL/idle timeout.
  • Resume attempts for sessions referencing the deleted bundle will fail with 502.

Dashboard

Usage and sandbox-history reads for account monitoring.

GET/v1/dashboard/overviewBearer JWT

Usage summary — sandbox count, LLM tokens, CPU-seconds, tool-call count.

Response

{
  "sandbox_count": 42,
  "input_tokens": 150000,
  "output_tokens": 50000,
  "cpu_seconds": 3600,
  "billable_tool_calls": 12
}

Status codes

200OKoverview computed
GET/v1/dashboard/sandboxesBearer JWT

Paginated list of recent sandboxes with timing and billing metadata.

Query parameters

NameTypeReqDefaultDescription
limitintegerno20Max items per page.
offsetintegerno0Skip this many items.

Response

{
  "sandboxes": [ { ... } ],
  "total": 42
}

Status codes

200OKpage returned
GET/v1/dashboard/usage/dailyBearer JWT

Per-day usage breakdown for a date range.

Query parameters

NameTypeReqDefaultDescription
fromstring (YYYY-MM-DD)yesStart date, inclusive.
tostring (YYYY-MM-DD)yesEnd date, inclusive.

Response

{
  "entries": [
    { "day": "2026-04-01", "input_tokens": 10000, "output_tokens": 3000, "cpu_seconds": 300 }
  ]
}

Status codes

200OKentries returned

Billing

Credit balance, Stripe checkout, transactions, and auto-topup configuration.

GET/v1/billing/balanceBearer JWT

Current credit balance in cents (smallest currency unit).

Response

{ "credit_balance": 84750 }

Status codes

200OKbalance returned
POST/v1/billing/checkoutBearer JWT

Create a Stripe Checkout session to add credits. Returns a redirect URL.

Request

{ "credit_amount": 1000 }

Response

{ "url": "https://checkout.stripe.com/..." }

Status codes

200OKsession created
400Bad requestinvalid or below-minimum amount
GET/v1/billing/transactionsBearer JWT

Paginated billing transactions (top-ups, deductions, refunds).

Query parameters

NameTypeReqDefaultDescription
limitintegerno20Max items per page.
offsetintegerno0Skip this many items.

Response

{
  "transactions": [
    { "id": "txn_...", "amount": -25, "description": "Sandbox usage", "timestamp": "..." }
  ]
}

Status codes

200OKpage returned
GET/v1/billing/auto-topupBearer JWT

Read the current auto-topup configuration.

Response

{
  "enabled": true,
  "threshold_amount": 100,
  "topup_amount": 500
}

Status codes

200OKconfig returned
POST/v1/billing/auto-topupBearer JWT

Update the auto-topup configuration.

Request

{
  "enabled": true,
  "threshold_amount": 100,
  "topup_amount": 500
}

Response

{ "status": "updated" }

Status codes

200OKconfig persisted
400Bad requestinvalid amounts

Workspaces

S3-backed persistent storage scoped to (user, app_id, capset_id). Mounted into sandboxes with workspace_enabled.

GET/v1/workspacesBearer JWT

List all workspaces for the authenticated user.

Response

{
  "workspaces": [
    { "app_id": "my-app", "capset_id": "default", "size_bytes": 1048576 }
  ]
}

Status codes

200OKlist returned
GET/v1/workspaces/{appID}/{capsetID}Bearer JWT

List files inside a workspace.

Query parameters

NameTypeReqDefaultDescription
prefixstringnoFilter to keys beginning with this prefix.
limitintegerno20Max items per page.
tokenstringnoPagination cursor returned by a previous call.

Response

{
  "files": [
    { "path": "output.json", "size": 1024, "modified": "2026-04-20T10:00:00Z" }
  ],
  "next_token": "..."                   // absent when no more pages
}

Status codes

200OKpage returned
GET/v1/workspaces/{appID}/{capsetID}/files/*Bearer JWT

Download a single file from a workspace. Returns a presigned S3 URL to redirect to, or the raw bytes depending on negotiation.

Response

{
  "url": "https://s3.../presigned...",
  "expires_in": 3600
}

Status codes

200OKURL or bytes returned
404Not foundno such file at this path
PUT/v1/workspaces/{appID}/{capsetID}/files/*Bearer JWT

Upload a file into a workspace via a presigned S3 URL.

Response

{
  "url": "https://s3.../presigned...",
  "expires_in": 3600
}

Status codes

200OKpresigned URL issued
DELETE/v1/workspaces/{appID}/{capsetID}/files/*Bearer JWT

Delete a single file from a workspace.

Response

{ "status": "deleted" }

Status codes

200OKfile deleted
404Not foundno such file at this path
DELETE/v1/workspaces/{appID}/{capsetID}Bearer JWT

Delete an entire workspace. All files under the (app_id, capset_id) scope are removed.

Response

{ "status": "deleted" }

Status codes

200OKworkspace deleted
404Not foundno such workspace
GET/v1/workspaces/{appID}/{capsetID}/usageBearer JWT

Storage usage for a workspace — size and file count.

Response

{
  "total_size_mib": 4.2,
  "file_count": 17
}

Status codes

200OKusage returned