HTTP API
Shardflux HTTP API reference. Base URL, /v1 versioning, API keys, people's sessions, tool tokens, idempotency, pagination, operations, rate limits and cells.
Overview
The Shardflux API has two parts:
| Part | Base URL | Authorization | Serves |
|---|---|---|---|
| Application API | https://api.shardflux.dev/v1 |
Authorization: Bearer sfk_<key id>_<secret> (a project API key), or Authorization: Bearer sfu_<token> (a person's session) |
Workspaces and their lifecycle, operations, templates, secrets, volumes, egress, usage; with a person's session also the account, organizations, members, API keys and billing |
| Cell gateway | the workspace's cell_endpoint, /v1/workspaces/{workspace_id}/... |
Authorization: Bearer <tool token> |
Workspace tools: exec, terminals, processes, files, git, browser |
Every application API endpoint an API key can call is in Endpoints, generated from the OpenAPI document at https://docs.shardflux.dev/openapi.json. The cell gateway's endpoints are listed in Cell endpoints below. The TypeScript SDK, Python SDK and CLI call this API and handle retries, idempotency keys, operation waits and tool tokens for you.
Versioning
All routes are under /v1. Within /v1, new fields, enum values, error codes and details.reason values can appear
at any time: ignore fields you do not know, and treat an unknown error code or reason as a generic error (show
message, use retryable).
GET /v1/client-versions (no authentication, cached for an hour) names, for every Shardflux client package, the
latest published version and the oldest one this API still supports:
{ "clients": [{ "package": "@shardflux/cli", "ecosystem": "npm", "latest": "0.16.0", "minimum_supported": "0.1.0", "upgrade_command": "npm i -g shardflux", "release_notes_url": "https://www.npmjs.com/package/@shardflux/cli?activeTab=versions", "hold": null }] }The CLI (0.5.0+), the TypeScript SDK (0.9.0+), the Python SDK (0.5.0+) and the MCP server (0.4.0+) read it to tell you
when to update. latest is null for a package with no published release. The CLI (0.15.2+), the TypeScript SDK
(0.22.0+), the Python SDK (0.18.0+) and the MCP server (0.15.0+) take the latest release from npm or PyPI and use this
latest when the registry does not answer; hold, when set, names a release they do not recommend.
Authentication
API keys
A project API key has the form sfk_<key id>_<secret>. The key id is public; the secret is shown once, when you
create the key in the console. Send it on every application API request:
curl -sS https://api.shardflux.dev/v1/me -H "Authorization: Bearer $SHARDFLUX_API_KEY"- An API key belongs to one project. Workspaces, secrets and volumes it creates are in that project; other projects'
resources answer
404 not_found. - A key carries tool permissions, a subset of
exec,files,pty,process,gitandbrowser, which bound what its tool tokens may do.GET /v1/mereturns the key's organization, project andtool_permissions. - An API key is a server credential. Keep it out of browsers and client-side code.
- A missing or non-Bearer
Authorizationheader, and a malformed, unknown, revoked or expired key, are401 unauthenticated. Cookies are ignored.
A person's session
The second credential on /v1 is a person's CLI session, sfu_ followed by 43 characters. The
shard CLI (0.5.0+) and the SDKs' ShardfluxAccount
(TypeScript 0.9.0+, Python 0.5.0+) sign in with it, so
everything a person does in the console works without a browser. Use them rather than these routes directly.
curl -sS https://api.shardflux.dev/v1/auth/session -H "Authorization: Bearer $SHARDFLUX_SESSION_TOKEN"- Signing up from an agent.
POST /v1/auth/signupanswers with a session at once (201 created, 200 signed in):{status, created, access, user, session_token, session_expires_at}. Withemailonly, a new account hasaccess: provisional: every/v1route works on the default plan except Checkout, the billing portal, invoices, spend policy, invitations and credential changes, which need a verified email.POST /v1/auth/verify-email/codewith{}emails a 6-digit code and with{code}verifies the address; nothing is signed out. Terms:accept_terms: trueonsignupandregistersays the person agreed to the Terms of Service and acknowledged the Privacy Policy; a new account records when, and which version (terms_version, the effective dateYYYY-MM-DD; default: the current one). - Signing in.
/v1/authhas the console's sign-in routes:register,verify-email(andverify-email/resend),login,mfa/challenge,session,logout,logout-all,sessions,step-up,password/change,password/reset/request,password/reset/confirm,email/change,email/change/confirmand themfa/totpandmfa/recovery-codesroutes.GET /v1/auth/identitieslists the account's sign-in methods (has_password, and the linked Google and GitHub identities);DELETE /v1/auth/identities/{provider}removes one after a step-up (409 conflictwithdetails.reasonlast_sign_in_methodwhen it is the only one). Google and GitHub sign-in run in the console. Where the console gets a cookie, the answer carriessession_token(sfu_...) andsession_expires_at. Emailed links are the console's (https://app.shardflux.dev/auth/verify-email#token=...): post thetokenof the link. - Rotation. Completing a sign-in, a step-up, a password change and turning two-factor authentication on or off
return a new
session_tokenand revoke the previous one. - Lifetime. A session lasts 30 days from its last use and at most 90 days. The person sees it in the console
(Settings > Account & security) and can sign it out there;
POST /v1/auth/logoutends it. - What it can do. Every
/v1route takes it as the person: their memberships and roles decide, and routes that need a project takeproject_id. Account routes (account and organization deletion and exports, the audit export, Checkout, the billing portal, invoices, spend alerts, template publish and archive, members, invitations and API keys) accept only a person's session; an API key gets403 forbiddenthere. - Refusals. An unknown, revoked or expired session is
401 unauthenticated. A session waiting for its second factor gets403 mfa_required, an unverified email403 email_unverified, and a sensitive action without a recent step-up403 step_up_required. See Errors. - A project API key is not a session: the
/v1/authroutes that need a session answer it with401.
Tool tokens
The cell gateway does not accept API keys. It accepts workspace tool tokens: short-lived (at most 15 minutes) ES256 tokens bound to one workspace, one set of tools and the workspace's current ownership epoch.
curl -sS -X POST "https://api.shardflux.dev/v1/workspaces/$WORKSPACE_ID/tool-tokens" \
-H "Authorization: Bearer $SHARDFLUX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"agent_label": "my-agent", "tools": ["exec", "files"]}'| Request field | Meaning |
|---|---|
agent_label |
Optional attribution label, 1-100 characters. One agent session per workspace, principal and label. |
tools |
Optional subset of the key's tool permissions. Asking for more is 403 forbidden with details.denied_tools. |
The response (ToolToken) has token, token_type (Bearer), issued_at, expires_at, workspace_id,
agent_session_id, ownership_epoch, tools, audience, kid and cell_endpoint. Send token to
cell_endpoint.
- A token works for tool calls while the workspace runs. A suspended workspace gets a token too: with it the cell
answers only reads of the workspace's disk (
GET .../files,files/stat,files/list,POST .../files/search, withX-Served-From: disk) and every other call with409 workspace_not_running. While a suspend or a requested resume is in progress, the request is409 conflictwithdetails.reason: workspace_not_runningand the operation; a deleted workspace is409 conflictworkspace_deleted. - After a suspend, resume or move the workspace's epoch changes and the cell answers
409 stale_epoch: get a new token and reconnect. - Revoking an API key or deleting a workspace stops new tool calls within 30 seconds.
POST /v1/workspaces/openreturns a tool token with a ready workspace (see Open a workspace).
Requests and responses
- Bodies are JSON: send
Content-Type: application/jsononly with a body. Unknown properties are rejected with422 validation_failed. - IDs are UUIDv7 strings in lowercase canonical form. Your own names (
workspace_key, template slugs) are separate fields. - Timestamps are RFC 3339 in UTC with
Z. Units are in field names:cpu_millis(1000 = one vCPU),memory_mib,disk_gib,cpu_seconds,egress_bytes. - Every response carries
X-Request-Id. Send your ownX-Request-Id(8-64 characters ofA-Z a-z 0-9 . _ -) to correlate requests; otherwise the server generates one. Errors carry it asrequest_id. - Errors use one envelope, described in Errors:
{
"error": {
"code": "conflict",
"message": "The workspace has a suspend operation in progress; retry when it completes.",
"request_id": "req-9b0e1d2c3f4a5b6c7d8e9f00",
"retryable": true,
"operation_id": "01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33",
"details": {
"reason": "operation_in_progress",
"active_operation_id": "01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33",
"active_operation_kind": "suspend"
}
}
}Idempotency
Send an Idempotency-Key header (1-255 visible ASCII characters) on requests that create something or start an
operation, so a retry after a network failure cannot do it twice.
- The server stores the response per principal, route and key for 24 hours. A retry with the same key and the same body returns the original status and body.
- The same key with a different body is
422 idempotency_mismatch. - A duplicate that arrives while the first request is still running waits and then replays, or gets a retryable
409 conflict. Retry the same request with the same key. - Generate one key per intent (for example a UUID per user action) and reuse it for every retry of that intent.
Routes that accept it include POST /v1/workspaces/open, POST /v1/workspaces/{id}/suspend,
.../suspend-when-idle, .../resume, .../snapshot, .../fork, .../close, .../reset, .../save-as-template,
PATCH /v1/workspaces/{id}/caps,
DELETE /v1/workspaces/{id}, secret creation and rotation, template builds, draft create, discard, states, test
instances and publish, and volume create, delete, attach and detach. On the cell gateway, PUT .../files and
POST .../files/patch accept it too (up to 100 characters of A-Z a-z 0-9 . _ : -); an execution of a file-first
workspace is idempotent by its execution_id instead.
Pagination
Lists take ?limit= and ?cursor= and answer:
{ "data": [], "next_cursor": null }Pass next_cursor as cursor to get the next page; null means the last page. Most lists take limit from 1 to 200
(default 50); file trees, diffs and workspace changes take up to 1000. Cursors are opaque.
Operations
Lifecycle calls (open, suspend, resume, snapshot, fork, delete, close, reset) create an operation and answer 202:
{ "operation": { "id": "01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33", "kind": "suspend", "state": "queued", "workspace_id": "01a0e5a8-3edd-74ba-b489-d62b8925e342", "created_at": "2026-09-28T12:00:00Z", "updated_at": "2026-09-28T12:00:00Z" } }| Field | Meaning |
|---|---|
kind |
open, suspend, resume, fork, snapshot, restore, delete, reset, resize, layer_snapshot, volume operations, ... |
state |
queued, capacity_pending, running, succeeded, failed or canceled. The last three are terminal. |
state_reason |
Why it is in that state, for example template_downloading while running. |
created_at, started_at, completed_at |
When it was created, first entered running, and finished. |
error |
On failure: {code, message, retryable, details}. |
result |
On success: what the operation produced (for example a checkpoint id). |
Poll GET /v1/operations/{operation_id} until the state is terminal. Operations stay readable after their workspace
is deleted. GET /v1/workspaces/{id}/operations lists a workspace's operations, newest first.
POST /v1/workspaces/{id}/suspend-when-idle with {"after_seconds": 60} (0 to 3600; 0 = as soon as it is idle)
creates no operation: it records a suspend when idle and answers 202 with
{workspace, operation: null, suspend_request: {requested_at, after_seconds, not_before}}. The suspend, when the
workspace has been idle that long, is a suspend operation of its own. If a suspend is already in progress, the answer
is {workspace, operation: <that suspend>, suspend_request: null} and nothing is recorded. DELETE on the same path
cancels a pending request (200 with the workspace, idempotent). The workspace view shows it as
idle.suspend_request.
GET /v1/operations/{operation_id}/progress reads a suspend's live progress without waking the workspace:
{progress: {operation_id, state, workspace_state, phase, cancelable, supported, safe_action, phase_started_at, deadline_at}}; GET /v1/operations/{operation_id} of a suspend carries the same progress.
POST /v1/operations/{operation_id}/cancel (with an Idempotency-Key) cancels a suspend before its capture starts,
while progress.cancelable is true. It answers 202 with {operation, progress} (progress.accepted: true) while
the workspace is put back as it was, and 200 once the operation is canceled with error.code: suspend_canceled;
poll the operation until then. Once capture has started it answers 409 conflict suspend_capture_started: wait for
the operation, then resume (errors).
Bounded waits
Instead of polling in a loop, send Prefer: wait=<seconds> (at most 20):
curl -sS "https://api.shardflux.dev/v1/operations/$OPERATION_ID" \
-H "Authorization: Bearer $SHARDFLUX_API_KEY" \
-H "Prefer: wait=20"While the operation is not terminal, the server holds the response until its state or state_reason changes, the
wait elapses, or you disconnect. The response is always the normal representation, with
Preference-Applied: wait=<seconds>. Without Preference-Applied the server did not wait: poll with backoff. Template
builds (GET .../template-builds/{build_id}) take the same header.
Start deadlines
A queued open, resume or fork (capacity_pending) carries error.details.deadline_at, 15 minutes after the operation
was created, and error.details.queue_position, its place in line (starts are served in the order they were
requested). While new capacity is starting for it
(error.details.pool.scale_out: requested), the deadline moves to 5 minutes after pool.expected_ready_by, at most
an hour after creation. While the workspace's files are being moved to new storage (state_reason: preserving_files),
the deadline is an hour after creation. Past the deadline, or after 2 minutes when no more capacity can be added right now, the
operation fails with error.code: capacity_unavailable and retryable: true (details.retry_after_seconds in the
second case, and at once when the start queue is full): nothing was started, and a suspended workspace stays suspended. Send the same request again.
Open a workspace
POST /v1/workspaces/open creates the workspace on first use and reconnects to or resumes it afterwards. It never
resets an existing workspace.
curl -sS -X POST https://api.shardflux.dev/v1/workspaces/open \
-H "Authorization: Bearer $SHARDFLUX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Prefer: wait=20" \
-d '{"key": "customer-42/main", "template": "default"}'| Status | Meaning |
|---|---|
200 |
The workspace is running and ready. cell_endpoint and tool_token are set. |
202 |
Poll operation (see above), then request a tool token. |
The body is {workspace, operation, cell_endpoint, tool_token, created}. created is true when this request
created the workspace (a new key, or a key whose session ended) and false when it reconnected to or resumed one.
With Prefer: wait, the open is held until its operation is terminal or the wait elapses. Concurrent opens of one key
share one workspace and one operation, and exactly one of them answers created: true.
Body fields: key (required, 1-200 characters without control characters), template (required, a slug; default
is the platform default template, python-node-browser), caps
(cpu_millis, memory_mib (the most memory it may use), disk_gib and memory_mib_held (what it holds while idle);
see elastic memory; allocation_mode is deprecated, accepted and ignored),
agent_label, tools, secrets (up to 50 names), inputs
({NAME: value}), lifetime (persistent or session), mode (processful, the default for a new key, or
file_first; see File-first workspaces), like and volumes (below). The workspace view
reports mode and tree_revision (null for a processful workspace), and memory: allocation_mode (elastic,
or fixed for a workspace that holds its whole maximum), promised_mib (the most it may use), held_mib (what it
holds while idle) and plugged_mib (what it holds above its floor while it runs, else null). See Workspaces and
Lifecycle.
like names another workspace's key within the selected project and applies only to a new key: the new workspace
gets a fresh disk with that workspace's exact template version, sizes, secret bindings, inputs and volumes, and the
fields you send override what it copies. An existing key keeps its disk and template. volumes maps guest mount paths
to project volume names or {name, mode} (ro or rw): a supplied set replaces the workspace's mounts and omission
preserves them. Existing-key replacements advance one operation per open; waited SDKs continue under one budget. See
Volumes.
Resume a workspace
POST /v1/workspaces/{id}/resume creates a resume operation (or joins the resume or open already running) and
answers 202 with it. Send Prefer: wait=<seconds> (at most 20) and an optional body {"agent_label": ..., "tools": [...]} to have it held like an open:
curl -sS -X POST "https://api.shardflux.dev/v1/workspaces/$WORKSPACE_ID/resume" \
-H "Authorization: Bearer $SHARDFLUX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Prefer: wait=20" \
-d '{"agent_label": "my-agent", "tools": ["exec", "files"]}'| Status | Meaning |
|---|---|
200 |
The workspace runs: {workspace, operation, cell_endpoint, tool_token}, with Preference-Applied: wait=<seconds>. The token is for agent_label and tools, so the next tool call needs no token request. A workspace that was already running answers 200 at once with operation: null and a token. |
202 |
Not finished within the wait, or the resume failed: {operation, workspace}. Poll the operation, then request a token. |
The succeeded operation's result says how the workspace came back: resume_path (local_cache, prestaged or
download: memory restored from the checkpoint; thaw: resumed while its suspend was still being written, the same
VM continued in place; cold_boot: the saved disk was booted after a platform runtime change, or the workspace's disk
after its host failed; reset_blank_layer: the first start after a reset), memory_restored
(true or false) and, for a cold boot, cold_boot_reason (runtime_changed or host_lost). A workspace restored
after its host failed also has host_lost (detected_at, restored_from: disk or checkpoint, and for a
checkpoint restored_checkpoint_id and state_as_of). See
When a resume boots instead and
If a workspace's host fails.
Without the preference, a workspace that is already running is 409 conflict (already_running). tools beyond the
API key's tool permissions are 403 forbidden before anything is created. The SDKs (@shardflux/sdk 0.9.0+,
shardflux 0.5.0+), the CLI (0.5.0+) and the MCP server (0.4.0+) wake a suspended workspace with this one
request.
Fork a workspace
POST /v1/workspaces/{id}/fork with a body {"key": ...} creates a fork operation and answers 202 with it.
Send Prefer: wait=<seconds> (at most 20) and optionally agent_label and tools to have it held until the copy
runs:
curl -sS -X POST "https://api.shardflux.dev/v1/workspaces/$WORKSPACE_ID/fork" \
-H "Authorization: Bearer $SHARDFLUX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Prefer: wait=20" \
-d '{"key": "customer-42/experiment", "agent_label": "my-agent", "tools": ["exec", "files"]}'| Status | Meaning |
|---|---|
200 |
The copy runs: {operation, workspace, source_workspace_id, cell_endpoint, tool_token}, with Preference-Applied: wait=<seconds>. workspace is the copy, and the token is the copy's, for agent_label and tools, so the next tool call needs no token request. |
202 |
Not finished within the wait, or the fork failed: {operation, workspace}. Poll the operation, then request a token for the copy. |
tools beyond the API key's tool permissions are 403 forbidden before the copy is created.
The copy keeps the source's secret bindings, text inputs and egress policy override. The override becomes version 1 of
the copy's own history (GET /v1/workspaces/{id}/egress-policy on the copy), so the copy starts under the same
outbound rules; later changes to either workspace's override apply to that workspace only.
Resize a workspace
PATCH /v1/workspaces/{id}/caps changes the memory, CPU or disk of any workspace, running or suspended, without a
restart. The body has any of memory_mib (the maximum), memory_mib_held (the floor), cpu_millis and disk_gib,
at least one; allocation_mode is deprecated, accepted and ignored. Each replaces the stored value, so every later
start uses it.
Send an Idempotency-Key, and Prefer: wait=<seconds> (default 20, at most 55) to have the answer held until the
resize has finished:
curl -sS -X PATCH "https://api.shardflux.dev/v1/workspaces/$WORKSPACE_ID/caps" \
-H "Authorization: Bearer $SHARDFLUX_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Prefer: wait=20" \
-d '{"memory_mib": 6144}'| Status | Meaning |
|---|---|
200 |
The resize finished: the result below, with the workspace's caps. |
202 |
Not finished within the wait: {operation, workspace}. Poll the operation; its result is the same body. |
{
"workspace_id": "01a0e5a8-3edd-74ba-b489-d62b8925e342",
"operation_id": "01a0f2c4-5d10-7a3e-9b21-6c4f8e2a1d07",
"state": "running",
"memory": {"applies_at": "now", "allocation_mode": "elastic", "requested_mib": 6144, "target_mib": 6144,
"previous_mib": 4096, "applied_mib": 6144, "held_mib": 1024, "converged": true,
"at_maximum": false, "limit_reason": null, "reason": null},
"caps": {"cpu_millis": null, "memory_mib": 6144, "disk_gib": null, "allocation_mode": "elastic", "memory_mib_held": 1024}
}The result has memory, cpu and disk for the resources the request named (memory_mib_held reports under
memory):
| Field | Meaning |
|---|---|
applies_at |
now: applied live, with every process and its memory kept. resume: the workspace is suspended and resumes at the new size, before its first call. next_start: applies the next time the workspace boots; nothing restarts now. |
requested_*, target_*, previous_*, applied_* |
What the request asked for, what the workspace gets within its bounds, what it had, and what is live after the call (null when it is not running). |
at_maximum |
true when the target is the most the workspace can have: the smaller of the plan's and the template's maximum. With previous_* equal to target_*, the resize changed nothing: a workspace already uses its full CPU and memory from boot and its disk grows on its own as it fills. |
limit_reason |
plan or template: lowered to that maximum, as caps at an open. host_capacity: part of a grow was added; resize again for the rest. shrink_stalled: the processes use the memory, so the workspace keeps it. Otherwise null. |
reason |
Why it does not apply now: suspended, stopped, boot_vcpus (more vCPUs than the workspace started with), region (more memory than it can add while it runs), below_base (less than the 1 GiB it boots with), legacy_layout (use upgrade to enable live memory resizing). Otherwise null. |
allocation_mode, held_mib, converged |
Memory only: elastic (or fixed for a workspace that holds its whole maximum), the floor (null when fixed), and whether a live change has reached the workspace. |
The SDKs wait for a 202 themselves: workspace.resize() (TypeScript 0.14.0+), ws.resize() (Python 0.10.0+).
Refusals are in Errors: shrink_not_supported, exceeds_memory_mib, not_supported_for_mode
and workspace_deleted.
The resize body accepts at_least: true. Each named numeric resource at or below its current configured size is
unchanged; a smaller disk is a no-op, so memory in the same request can grow. A larger value uses the usual bounds.
The response reports each named resource with its requested, previous, target and applied fields.
Upgrade a workspace
POST /v1/workspaces/{id}/upgrade accepts { "at": "now" } or { "at": "next_resume" } and returns an operation.
The cold start keeps the workspace disk and ends memory and processes. A scheduled operation reports
applied: next_resume; the applying start reports applied: now, cold_boot_reason: upgraded,
memory_restored: false, and the applied upgrade target. A resume that continues the workspace in place
(resume_path: thaw) keeps a scheduled target pending. Workspace views expose upgrade_available and
upgrade_pending. Supports Idempotency-Key. See lifecycle.
Inbound ports
Serve a TCP port of a processful workspace at https://<port>-<handle>.shardflux.app (guide). The
routes need the workspace's lifecycle permission, like labels:
| Route | Answer |
|---|---|
PUT /v1/workspaces/{id}/ports/{port} |
201 exposed now, 200 already exposed (unchanged): {port, url, created_at, callback} |
GET /v1/workspaces/{id}/ports |
200 {ports: [{port, url, created_at, callback}]}, ordered by port |
DELETE /v1/workspaces/{id}/ports/{port} |
204. Its tokens, links and callback URL stop working at once, also when the port is exposed again |
POST /v1/workspaces/{id}/ports/{port}/tokens {ttl_seconds} |
201 {token, expires_at, url}: ttl_seconds 60 to 86,400 (default 3,600) |
POST /v1/workspaces/{id}/ports/{port}/links {ttl_seconds, path} |
201 {url, expires_at}: ttl_seconds 60 to 604,800 (default 86,400); path starts with / (default /) |
POST /v1/workspaces/{id}/ports/{port}/callback |
201 {url, created_at}: creates the callback URL, or replaces it. The URL is returned only here |
DELETE /v1/workspaces/{id}/ports/{port}/callback |
204 |
callback is {created_at} while the port has a callback URL, else null. A request to a port URL carries one of:
Authorization: Bearer sfp_...(a port token), orX-Shardflux-Token: sfp_...when the app readsAuthorizationitself;- the cookie a signed link sets (open the link's
urlin a browser: it sets the cookie for that host and redirects topath); - the callback URL's path: a request to
<callback url><rest>reaches/<rest>, query kept.
The app receives the public Host, X-Forwarded-For, X-Forwarded-Proto: https and X-Forwarded-Host, never the
platform's credential. A request that woke the workspace carries Server-Timing: sf-wake;dur=<ms>;desc="<frozen | hibernated | suspended>". Refusals: errors; limits: limits.
Rate limits
A request over a limit is 429 rate_limited with retryable: true and a Retry-After header in seconds (also in
details.retry_after_seconds when the API sets it). Wait that long before retrying.
| Limit | Scope |
|---|---|
Tool tokens (including the token of a ready open): 3600 per hour. Past it, an open still succeeds with tool_token: null; get a token later from POST /v1/workspaces/{id}/tool-tokens |
per API key |
| Template package lookups: 120 per minute | per organization |
Cell gateway: a workspace at a resource limit answers 429 with Retry-After: 1 |
per workspace |
A 429 can also come from the network edge, with Retry-After: 60 and request_id: "edge". Plan limits (sizes,
allowances) are not rate limits: they answer 402 or 403. The plan's workspaces working at once answers
429 quota_exceeded, which is retryable. See Limits.
Cell endpoints
The cell gateway serves a running workspace's tools at its cell_endpoint. Every call carries
Authorization: Bearer <tool token>; the token must name the workspace and include the tool the call needs.
CELL=$(jq -r .cell_endpoint token.json); TOKEN=$(jq -r .token token.json)
curl -sS -N -X POST "$CELL/v1/workspaces/$WORKSPACE_ID/exec" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Accept: application/x-ndjson" \
-d '{"session_id": "build-1", "argv": ["bash", "-lc", "make test"]}'One request starts the command and streams its output: with Accept: application/x-ndjson (or text/event-stream),
POST /exec answers 200 with the same events as GET /exec/{session_id}/output?follow=true from offset 0, and
X-Exec-Session-Id names the session. A dropped stream continues with GET /exec/{session_id}/output from the byte
offsets already read; the command never runs twice. Without that Accept, the start answers with the session JSON
and the output is read with GET /exec/{session_id}/output.
Method and path (under /v1/workspaces/{workspace_id}) |
Tool | Does |
|---|---|---|
POST /exec |
exec |
Starts a command (argv, no shell), and with Accept: application/x-ndjson or text/event-stream streams its output in the same response. resource_hint: on a workspace that adds memory as it grows (one started earlier), heavy gives it its memory before the command starts, light starts it at once, auto (the default) decides from the command; a workspace that sees its maximum from boot starts every command at once. Idempotent by session_id: 201 created, 200 it already existed and nothing ran again (a streamed start answers 200 and streams the existing session). cwd is an absolute path (default /home/user); a relative one is 422 validation_failed (invalid_cwd). |
GET /exec/{session_id} |
exec |
Session status. |
GET /exec/{session_id}/output |
exec |
Output from byte offsets (stdout_offset, stderr_offset) as NDJSON, or Server-Sent Events with Accept: text/event-stream. follow=true (default) keeps the stream open until exit. |
GET /exec/{session_id}/attach |
exec |
WebSocket: output with offsets, signal and cancel ({"type": "cancel", "grace_ms": 10000}, as POST .../cancel). |
POST /exec/{session_id}/signal |
exec |
Signals the session's process group. |
POST /exec/{session_id}/cancel |
exec |
Stops the command and every process it started, background and setsid processes included: SIGTERM, then SIGKILL after grace_ms (0 to 60000; 0 or omitted is 5000). Answers once they have all exited, also for a command whose supervisor was lost (lost). A command that already exited keeps its result, and anything it left running is stopped. While a process cannot exit yet (uninterruptible I/O): 503 dependency_unavailable with Retry-After; cancel again. Workspaces on python-node-browser 13, ubuntu-24.04 4, ubuntu-22.04 2, debian-12 2 and later template versions; earlier versions signal the command's process group. |
POST /pty |
pty |
Opens a terminal session (idempotent by session_id). |
GET /pty/{session_id}, DELETE /pty/{session_id} |
pty |
Status; close (SIGHUP, then SIGKILL after 2 seconds). |
POST /pty/{session_id}/resize, POST /pty/{session_id}/input |
pty |
Resize; write input bytes. |
GET /pty/{session_id}/attach |
pty |
WebSocket: input, output with offsets, resize. read_only=true watches without typing: input and resize are answered with an error message (forbidden, details.reason read_only_attach). passive=true never wakes or keeps the workspace awake while the terminal is idle. |
GET /processes, POST /processes/{pid}/signal |
process |
Lists and signals guest processes. |
GET /files?path= |
files |
Reads a file: offset and length, or a single Range: bytes=start-end. X-File-Size gives the size, X-File-Revision the SHA-256 of the whole file (files up to 16 MiB), and X-Served-From: disk a read of a sleeping workspace's disk. |
PUT /files?path= |
files |
Writes a file from a raw application/octet-stream body: atomic replace, or append=true. mode, create_parents. Acknowledged after fsync (durable: true). |
DELETE /files?path= |
files |
Removes a file or directory (recursive). |
GET /files/stat, GET /files/list, POST /files/mkdir, POST /files/move |
files |
Stat (revision=true adds the content's SHA-256, regular files up to 256 MiB), list, create a directory, move or rename. |
GET /computer |
computer |
The workspace desktop: {running, display, width, height, started_at, session, streams}. Never starts it. |
POST /computer |
computer |
Starts the desktop (width 640-2560, height 480-1600; default 1280x800). A no-op while it runs. |
DELETE /computer |
computer |
Stops the desktop; its windows close. |
POST /computer/actions |
computer |
Runs actions (Claude's computer toolset members with their parameters) in order; screenshot, settle_ms, format, quality. Starts the desktop first if needed. Returns {results, screenshot?, cursor, display}. |
GET /computer/frames |
computer |
WebSocket: the screen as images. Each change is a JSON {"type":"frame","seq","format","width","height","bytes","captured_at"} message followed by one binary message with the image; nothing while the screen is still; heartbeat every 20 s. format (jpeg, png), quality (1-100, default 70), max_fps (1-10, default 5). Starts the desktop first if needed; the workspace stays awake while it is open. Up to 4 streams per workspace (429 rate_limited, details.reason frame_streams_limit). |
GET /computer/vnc |
computer |
WebSocket (subprotocol binary): the desktop as VNC, RFB 3.8 bytes in binary messages both ways, as noVNC's RFB client speaks it. View only; control=true takes over: the agents' input actions wait while it is open and answer the action error taken_over. Starts the desktop first if needed; the workspace stays awake while it is open. Up to 4 per workspace (429 rate_limited, details.reason vnc_streams_limit). |
POST /computer/stream, DELETE /computer/stream |
computer |
Starts the viewer (interactive) and returns {port, path, interactive} to expose and link with the ports API; DELETE ends every view. |
POST /files/search |
files |
Searches file contents under path: pattern (literal, or RE2 with regex), case_insensitive, include and exclude globs, max_matches, max_file_bytes, context_lines. Returns {matches, truncated, stop_reason, files_scanned}. Read-only. |
POST /files/patch |
files |
Applies edits ([{old_text, new_text, replace_all}]) or a whole content atomically, optionally only at expected_revision (a SHA-256, or absent). Returns {path, revision, previous_revision, bytes_written, durable, replacements, file}. Accepts Idempotency-Key. |
GET /changes |
files |
A layered workspace's changes against its template (path_prefix, limit, cursor, hash, summary). |
POST /git/clone, GET /git/status, POST /git/commit |
git |
Clone (HTTPS only), status, add and commit. |
POST /browser/screenshot, POST /browser/content |
browser |
Navigates the workspace's headless Chromium; returns a PNG, or the rendered page as HTML or text. |
POST /keepalive |
any | Keeps the workspace from idle suspend for seconds, and resident (not parked). Not tool activity. |
POST /wake-hint |
any | A tool call is coming: a parked workspace starts waking. 202 {residency} (resident, frozen, hibernated, restoring); a suspended workspace is 409 workspace_not_running, and a wake past the plan's workspaces working at once is 429 quota_exceeded at once. Not tool activity. |
GET /executions/{execution_id} |
exec |
File-first workspaces: an execution's result (202 while it runs). See File-first workspaces. |
GET /idle |
any | The idle policy with its current timeout_seconds and basis, recent activity, idle_since, a pending suspend-when-idle request (suspend_request, else null) and suspend_at, the projected automatic suspend: the earlier of the policy's time and the request's. |
Sessions and offsets
Exec and terminal sessions have a session_id you choose (or the server generates). Starting an existing session
never runs anything again. Output is kept in the workspace and addressed by byte offsets that only grow: after a
dropped connection, reconnect with the last offsets you processed instead of starting a new command. Output events
have type (output, exit, heartbeat every 15 seconds, error), stream, offset and data (base64). Session
ids starting with sfstart. belong to the template's start commands: you can read them but not start one.
On a workspace that adds memory as it grows (one started earlier), a session whose
start grew the workspace (a heavy command, or resource_hint: "heavy") has memory_grow: {outcome, from_mib, want_mib, got_mib, deliver_ms},
with outcome delivered, partial (less than it asked for), missed (nothing could be added) or failed. A
session started on a new fork before the fork's memory caps take effect waits for them and reports them the same way.
The command starts in every case.
A start's timeout_ms (up to 604800000, a week) ends the command with SIGTERM, then SIGKILL after kill_grace_ms (0
to 600000; 0 or omitted is 5000). A cancel stops it now: SIGTERM, then SIGKILL after its grace_ms (0 to 60000; 0 or
omitted is 5000), and the answer comes once the command has ended.
A command that cannot start (a cwd that is not a directory, a program that is not on PATH, an unknown user) is
not an HTTP error: the start answers 201 with state: "failed_to_start" and error naming the cause, for example
working directory "/home/user/app" is not a directory. Nothing ran, so the session has no exit code and no output;
its output stream sends only the exit event.
Every exec and terminal runs with, lowest first: the guest's environment, the template's settings.env, the
workspace's text inputs, then the request's env. Bound secrets and secret_refs are added on top; a secret whose
name collides with any of those keys is 422 validation_failed (env_collision).
During lifecycle transitions
How the cell answers during transitions:
| Situation | Answer | What to do |
|---|---|---|
| A lifecycle operation holds the workspace (suspend, resume, fork, snapshot, ...) | 409 workspace_busy, retryable: true, operation_id, details.tool_gate: read_only during a snapshot capture (reads still work), else closed |
Wait for the operation, then retry. |
| The workspace is suspended | 409 workspace_not_running, except reads served from its saved disk (file read, stat, list and search) |
Call POST /v1/workspaces/{id}/resume (with Prefer: wait, it returns a token) or open the key again, then retry with the new token. |
| The workspace is parked while idle | Nothing: the call wakes it first, waiting while its host makes room when it has to. 503 service_unavailable (host_capacity) if it is still waiting when the call's wait ends (25 to 50 seconds, see limits), wake_failed if the wake failed. |
Retry after Retry-After. |
| The token's epoch is old | 409 stale_epoch |
Get a new tool token and retry. |
A refused call was never executed, so retrying it is safe. The SDKs, the CLI and the MCP server do this for you ("wake on use").
File-first workspaces
A workspace opened with "mode": "file_first" has no VM between commands (see File-first
workspaces). Its cell endpoint answers the same files routes from the workspace's file tree,
and runs each POST /exec as an execution:
curl -sS -X POST "$CELL/v1/workspaces/$WORKSPACE_ID/exec" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"execution_id": "build-42-attempt-1", "argv": ["bash", "-lc", "make -C /home/user/app"], "timeout_ms": 600000}'execution_idis required (8-128 characters ofA-Z a-z 0-9 . _ : -, starting with a letter or a digit); it is the idempotency key.output_limit_bytes(1 to 16,777,216, default 1 MiB) bounds stdout and stderr each.session_idis ignored.- The call answers when the command has ended:
201when this call ran it,200with the recorded result for any other call with the same id (after waiting for a running one). The same id with a different request is409 conflict(execution_id_reused). - The result:
execution_id,state(succeeded,failed,lost),base_revision,tree_revision,exit_code,term_signal,timed_out,stdoutandstderr(base64) withstdout_truncatedandstderr_truncated,changed([{path, change, type}], up to 10,000,changed_truncated),timings,error,created_at,finished_at. GET /executions/{execution_id}returns the result:200once it ended,202while it is queued or running,404for an unknown id. Results are kept for 7 days.- While an execution runs, a second one and every file change are
409 workspace_busy(execution_in_progress,details.execution_id).503 service_unavailable(no_execution_host, withRetry-After): retry with the same id. - Every files response carries
X-Tree-Revision.PUTandDELETE /files,files/mkdir,files/moveandfiles/patchtakeIf-Match: <tree revision>: another current revision is409 conflict(tree_revision_mismatch,details.current_tree_revision) and nothing changes.Idempotency-Keyreplays answer withIdempotent-Replayed: true. - Only paths under
/home/userexist (outside_tree_root). Exec sessions, terminals, processes, git, browser, changes, keepalive and idle are409 conflict(not_supported_for_mode), andwake-hintanswersresident.
WebSocket close codes
Exec and terminal attach streams close with 1000 after the exit event and 1001 when the gateway shuts down
(reconnect with your offsets). Otherwise the code is 4000 plus the HTTP status of the error: 4401
unauthenticated, 4403 forbidden, 4409 stale_epoch, workspace_busy, workspace_not_running or conflict,
4429 rate_limited or quota_exceeded (workspaces working at once), 4500,
4503 and 4504; 4410 means the workspace is gone. Before closing, the gateway sends
an error message with the full error envelope; the close reason is compact JSON with the error's code and
retryable (and operation_id, request_id when they fit). The SDKs read both, so a 4429 keeps its own code
(quota_exceeded or rate_limited) and a 4503 its details, such as reason: host_capacity (TypeScript 0.22.0+,
Python 0.18.0+).
Idle deletion retention
POST /v1/workspaces/open accepts retention: {"delete_after_idle_days": 14} for persistent workspaces.
PUT /v1/workspaces/{id}/retention takes that policy object or JSON null to follow the project default and
returns the workspace. GET and PUT /v1/projects/{id}/retention read and set a default policy:
{"delete_after_idle_days": 14, "labels": {"app": "chat"}}. All selector labels must match exactly; omit labels
to match every persistent workspace. A workspace's own setting wins. JSON null clears the project default.
Use a project key for its own project or an owner, admin or member session.
The workspace view includes retention: {"delete_after_idle_days": 14, "delete_at": "2026-10-18T12:00:00Z"},
or null when no policy applies. Days are integers from 1 through 3650. Calls and ongoing work postpone deletion;
the projected time moves with activity. The audit event for an automatic delete has reason: retention.
Volumes
Client support: TypeScript SDK 0.18.0+, Python 0.14.0+, CLI 0.13.2+, MCP 0.12.0+.
POST /v1/projects/{id}/volumes/open with {name, quota_gib?} reuses the project's volume of that name or creates
it (default 1 GiB). Mount volumes on a workspace with volumes on open; access follows the mount
mode. Shared volumes are outside workspace snapshots and forks. See limits.
Website sign-ins
Site logins keep website credentials in an encrypted store. A person's session manages their items; an organization
owner or admin can also manage project-scoped items. A project API key with browser permission can list and add
logins in its own project. Reveal requires a person's session. Every reveal and store fill is audited.
| Method and route | Request or result |
|---|---|
GET /v1/site-logins |
{data: [login]} metadata across the person's eligible projects; filters organization_id, project_id, site, created_by=agent and created_after (ISO time). Results are ordered by creation time. |
POST /v1/site-logins |
{site, username?, password?, totp?, passkeys?, related_origins?, scope?, organization_id?, project_id?, conversation_id?, created_by?} → login metadata, 201. |
PATCH /v1/site-logins/{id} |
Update scope, its project or thread id, username, site, related origins or values → login metadata. Value changes retain the previous encrypted version. |
DELETE /v1/site-logins/{id} |
Revoke the login → 204. |
POST /v1/site-logins/{id}/reveal |
{} → {password?, totp_seed?, totp_code?, totp?, passkey_count} on the person's device. Private keys are excluded from reveal. |
POST /v1/site-logins/{id}/vault-link |
{vault, item} → metadata with local_vault: {vault, item, synced_at}. Vault is hsec, 1password, bitwarden or keychain. |
scope defaults to agents: every agent the person runs in that organization. project selects project_id;
conversation selects conversation_id. organization_id is inferred from a selected project or thread, or the
person's sole eligible organization; supply it when selecting among several. API keys add with scope: "project".
A live login is identified by its organization, scope, scope id, site and username. Adding that identity again
returns 409 conflict with details: {reason: "site_login_exists", login_id}; update it through PATCH instead.
site is an HTTPS origin; local development also accepts HTTP localhost. related_origins lists the other exact
origins used by that site's sign-in flow. On creation they share the site's registrable domain (for example,
mail.example.co.uk and accounts.example.co.uk). The login owner, or an organization owner/admin for a project
login, can approve other origins through an audited PATCH. username is metadata and may be empty.
totp is {seed, digits?, period?, algorithm?}: base32 seed, digits (default 6), period in seconds (default 30),
and SHA1 (default), SHA256 or SHA512. passkeys contains {credential_id, rp_id, user_handle, private_key, sign_count, created_at}; private_key is PKCS#8 DER in base64. Handle value fields through a secure credential flow.
Lists return has_password, has_totp and passkey_count with metadata; a reveal additionally returns the current
TOTP code and its setup parameters. Reads of metadata do not return credential values.
For example, a stored login with an authenticator and a passkey has this metadata shape (selected fields):
{
"id": "01a166ab-6c8d-71ac-b3b8-e70802a020d8",
"site": "https://login.example.com",
"username": "test@example.com",
"scope": "agents",
"has_password": true,
"has_totp": true,
"passkey_count": 1,
"version": 1,
"created_by": {
"kind": "person"
}
}The full metadata view also carries organization_id, owner_user_id, scope_id, related_origins, local_vault,
created_at, updated_at and last_used_at. created_by.kind is person, import or agent; agent provenance can
name its thread, workspace and task. local_vault and last_used_at are null until a copy or use is recorded.
See website sign-in limits for field sizes, item counts and request timing.
Fill a website sign-in
POST /v1/credential-requests accepts {workspace_id, site, fields, public_key, account?, format?}. account selects
a saved username. fields selects username, password, totp or passkey; request format: 2 for passkeys and
structured authenticator settings. public_key is the requester's base64 X25519 public key. A workspace's credential
relay authenticates with its bearer token and accesses only that workspace's visible logins.
{
"workspace_id": "01a166ab-6fcd-76c4-88e2-ab4e1ef8eb28",
"site": "https://board.example.com",
"account": "board@example.com",
"fields": ["password", "totp"],
"public_key": "<requester's base64 X25519 public key>",
"format": 2
}Lookup checks the thread's scope, its project's scope and its starter's agents scope. Within a scope, account
selects the username; omitting it selects the most recently used login that has the requested fields. Exact site or
related-origin matches are required. A match returns state: "fulfilled" at once; its metadata response includes
id, workspace_id, project_id, site, account, fields, public_key, created_at, expires_at and
finished_at. GET /v1/credential-requests/{id} returns {request, ciphertext}; only that workspace's relay receives
the ciphertext. Only requested values are sealed. Format 2 plaintext is {v:2, login_id, username?, password?, totp_seed?, totp?, passkeys?}. The sealed envelope remains compatible with the original format; omitting format
keeps the original plaintext shape.
A new request carries the request lifetime in expires_at. A waiting sign-in can be
answered within that lifetime after the agent's synchronous call returns: saving the answer makes the next sign-in use the store and
sends the thread SIGN-IN SAVED: <site> (<username>). Call sign_in or credential_fill again. Expired requests leave
the sign-in list. Fulfilling an expired request returns 409 conflict with details.reason: credential_request_expired;
a request whose relay lost authorization returns 409 conflict with details.reason: credential_relay_revoked.
GET /v1/credential-requests returns {data, next_cursor}. With a person's session, omit project_id for pending
requests across the projects they work in; an API key stays in its project. GET /v1/credential-requests/events
returns credential events, including store changes, reveals and fills; personal events are visible to their owner.
Answer a website sign-in request
Your product can present a secure credential form on any device. Read the request with
GET /v1/credential-requests/{id} and send the supplied values directly to your secure handler, outside agent context.
The handler can save the login in the chosen scope and seal the requested fields to the requester's public key
(TypeScript SDK 0.32.5+):
import { sealCredential } from '@shardflux/sdk';
const { request } = await account.credentialRequests.get(requestId);
await account.siteLogins.add({
organizationId,
site: request.site,
username: credential.username,
password: credential.password,
...(credential.totp_seed ? { totp: { seed: credential.totp_seed } } : {}),
scope: 'agents',
});
const ciphertext = sealCredential(request.public_key, request, credential);
await account.credentialRequests.fulfil(request.id, ciphertext);Here credential contains only the requested username, password and totp_seed fields supplied through the secure
form. POST /v1/credential-requests/{id}/fulfil takes {ciphertext} and returns the updated request metadata with
state: "fulfilled". The ciphertext is a base64 sealed envelope bound to that request id and site. Saving the login
before fulfilling the request lets another computer or the agent's next call use the store. A one-time fill sends the
sealed answer without adding a login. POST /v1/credential-requests/{id}/deny with {} declines the request.
The workspace relay reads the encrypted answer and reports the fill with
POST /v1/credential-requests/{id}/result and {filled: true|false}; reporting clears the ciphertext. The compatibility
route PUT /v1/site-credentials with {workspace_id, site, credential, scope?} also remembers an answer in the central
store. Its scopes are conversation, project (default), and machine (the person's agents scope).
Store a login created by an agent
A workspace relay calls POST /v1/site-logins/agent with {workspace_id, site, related_origins?, username?, password?, password_secret?, totp?, passkey?, event, login_id?}. Events are created, saved, password_changed,
totp_enrolled, passkey_created and passkey_used. saved stores an existing account's login: it takes
password_secret, the name of an agent credential in that computer's environment, instead of password, and the
server copies that credential's value into the login. Without such a credential it returns 404 not_found with reason
credential_not_found. The response is login metadata. A new item belongs to the starter's agents scope; login_id can
select another item visible to the thread. Existing account names and related origins stay as saved; a person changes
them through PATCH. Changing them through the relay returns 403 forbidden. The owning person must be a current
non-billing organization member for agents-scope fills and stores; this also applies to an API key's creator.
The previous encrypted value is retained on a value change. Agent tools
send these values directly from the computer, and return metadata to the model.
GET /v1/site-logins?workspace_id={id} with that relay returns visible login metadata. A person's device can discover
new items with created_by=agent&created_after={time}, copy them to a local vault and record the copy with
vault-link. The TypeScript (0.32.5+) and Python (0.28.3+) references cover
the store methods; Website sign-ins shows the agent flow.
Browser sessions
GET /v1/browser-profiles returns metadata. PUT /v1/browser-profiles/{name} takes
{scope:"project"|{workspace_id}, sites:[{site,storage_state}]}; DELETE /v1/browser-profiles/{name}?site=…
revokes a selected site (omit site to remove the profile). State uses Playwright cookies and origins/localStorage;
workspace scope takes precedence over project scope.
Stored encrypted with AWS KMS; only this project's workspaces can load it, and every load is logged.
Session replay status
Exec and PTY session responses include optional tunnel_replay when the guest reports it: enabled retains tunnel bytes for reconnection, disabled opens the tunnel without replay, and disabled_memory_budget opens it without replay while the guest replay budget is in use. The field is absent when the guest does not report replay status. A tunnel without replay can be connected again after a lifecycle interruption.