TypeScript SDK
TypeScript reference for Node.js 24+: workspaces by key, commands, files, lifecycle, templates, agent tools and the account plane.
Install
npm install @shardflux/sdkThe package:
- is ESM only and needs Node.js 24 or later;
- uses pooled HTTP connections on Node 26. Reading a YAML
template.yamluses the optional peer dependencyyaml(npm install yaml); JSON template files need nothing; - is typed from the published OpenAPI documents and exports those types (
paths,components,WorkspaceView,Operationand more); - exports
SDK_VERSIONand sendsUser-Agent: shardflux-sdk-ts/<version>.
A feature marked with a version, such as (0.9.0+), is not in earlier releases. Check your version with
npm ls @shardflux/sdk or the exported SDK_VERSION.
Quick start
Create a project API key in the console (sfk_<key id>_<secret>) and keep it on the server. An API key is a server
credential: never put one in a browser bundle.
import { workspace } from '@shardflux/sdk'; // reads SHARDFLUX_API_KEY
const run = await workspace('customer-42/main', { template: 'default' }).exec('python3 -c "print(40 + 2)"');
console.log(run.stdout.trim()); // 42The key names the workspace: the first call creates it, later calls (from any process) reuse it. See Workspaces by key. The same workspace with every lifecycle step explicit:
import { Shardflux, formatTiming } from '@shardflux/sdk';
const cloud = new Shardflux(); // SHARDFLUX_API_KEY (0.15.0+)
const open = () => cloud.workspaces.open({ key: 'customer-42/main', template: 'default' });
// 1. Open the workspace (created on first use) and run a command.
const workspace = await open();
const run = await workspace.cell().exec.run(['python3', '-c', 'print(40 + 2)']);
if (run.exitCode !== 0) throw new Error(`python3 exited ${run.exitCode}: ${run.stderr}`);
console.log(run.stdout.trim()); // 42
// 2. Write a file, then suspend and wait until the suspend has finished.
await workspace.cell().files.write('/home/user/notes.txt', 'hello from the SDK\n');
await workspace.suspend({ wait: true });
// 3. Open the same key again: the workspace resumes with the file in place.
const again = await open();
console.log(await again.cell().files.readText('/home/user/notes.txt'));
console.log(formatTiming(again.lastTiming!)); // where the resume's time wentopen() waits until the workspace is running. Opening the same key again never resets it: files, installed packages
and running processes are still there. See Workspaces and Lifecycle.
Configuration
import { Shardflux } from '@shardflux/sdk';
const cloud = new Shardflux({
apiKey: process.env.SHARDFLUX_API_KEY!, // default (0.15.0+): SHARDFLUX_API_KEY
baseUrl: process.env.SHARDFLUX_API_URL, // default (0.15.0+): SHARDFLUX_API_URL, else the production API
timeoutMs: 30_000,
maxRetries: 2,
admissionTimeoutMs: 120_000, // (0.22.0+) how long a call waits for room
});| Option | Default | Meaning |
|---|---|---|
apiKey |
SHARDFLUX_API_KEY (0.15.0+); required before |
Project API key, sfk_<key id>_<secret>. A missing key throws when the client is created. |
baseUrl |
SHARDFLUX_API_URL (0.15.0+), else https://api.shardflux.dev |
API origin. |
timeoutMs |
30000 |
Timeout of one API request, in milliseconds. |
maxRetries |
2 |
Retries of safe or idempotent requests after a transient failure. See Retries and idempotency. |
admissionTimeoutMs |
120000 |
(0.22.0+) How long a call refused before it ran (503 host_capacity, 429 quota_exceeded) keeps being sent again, whatever its method. The workspace handles' cell() clients use it too. See Retries and idempotency. |
fetch |
pooled HTTP/1.1 on Node 26; native elsewhere | Your own fetch, used unchanged. See connection settings below. |
sessionToken |
none | (0.32.3+) A signed-in person's session (sfu_..., or a function that returns it) next to the project key. A call the key answers 404 not_found for, such as another project's workspace, conversation or task, is sent again with the session, and later calls about it go with the session. The session's answer is returned as it came, errors included. |
userAgent |
shardflux-sdk-ts/<version> |
User-Agent header. |
onProgress |
none | (0.6.0+) Listener for the progress of every traced call made through this client. See Timing and progress. |
versionCheck |
true |
(0.9.0+) false turns off the background update check; { package, version } checks a tool built on the SDK instead. See Version check. |
Besides SHARDFLUX_API_KEY and SHARDFLUX_API_URL (0.15.0+), the SDK reads SHARDFLUX_HTTP_KEEPALIVE and the
version check's opt-outs (SHARDFLUX_NO_UPDATE_CHECK, NO_UPDATE_NOTIFIER). Options you pass win.
From 0.12.0, Node 26 uses a private HTTP/1.1 connection pool by default. Other runtimes use native fetch.
SHARDFLUX_HTTP_KEEPALIVE=0 forces connection closure for diagnosis; =1 selects native pooling. A caller-supplied
fetch is unchanged, and the SDK does not change the global dispatcher.
From 0.13.1 the pool keeps an idle connection for 5 minutes, so the call after an agent's pause between turns
reuses it instead of opening a new TCP and TLS connection. A request the SDK may retry (a GET, a request with an
Idempotency-Key, a read-only POST such as files search) whose pooled connection closes before the response arrives
is sent again at once on a new connection; that resend does not count against maxRetries, and its progress retry
event has delayMs: 0.
The client
new Shardflux(options) exposes:
| Member | What it covers |
|---|---|
workspace(key, params) |
(0.15.0+) A workspace named by its key, created by its first call. See Workspaces by key. |
workspaces |
Open, look up, list and wait for workspaces and their operations. |
templates |
Templates, file trees and diffs, builds, uploads, test instances, drafts. |
secrets |
Customer secrets (values are write-only). |
egress |
Outbound allowlists of a project or workspace: getProject, putProject, projectVersions, getWorkspace, putWorkspace, clearWorkspace, workspaceVersions. |
volumes |
Shared persistent storage attached to workspaces: create, get, list, listAll, delete, attachments, listAttached, attach, detach. |
usage |
summary, series, workspace, estimate, grants, spend, spendPolicy (by organization id). See Usage and overage. |
billing |
catalog() and subscription(organizationId) (read only). |
me() |
The API key's principal: organization, project and tool permissions. |
entitlements(organizationId) |
Resolved plan limits, allowances and policies. |
request(method, path, init?) |
Any /v1 route with the SDK's authentication, retries and error handling. |
sendFeedback(params) |
(0.9.0+) Feedback straight to the Shardflux team; see Feedback. |
fetchBillingCatalog({ baseUrl?, fetch?, timeoutMs? }) reads the public plan catalog without an API key.
ShardfluxAccount (0.9.0+) is a second client, for what a person does in the console: see
Account.
Usage and overage
cloud.usage reads the organization's usage. API keys see organization totals and their own project's workspaces.
const s = await cloud.usage.summary(orgId);
if (s.allowance_exhausted) console.log('starts are refused:', s.exhausted_reason);
const cap = s.spend_cap; // (0.10.0+)
const usd = (minor: number) => `$${(minor / 100).toFixed(2)}`;
if (cap.state === 'accruing' || cap.state === 'warning') {
console.log(`overage ${usd(cap.charges_minor)} of ${usd(cap.effective_cap_minor)}; cap reached ${cap.projected_reached_at ?? 'not this period'}`);
}With opt-in overage on, workspaces keep opening and running past the CPU-hours and RAM
GiB-hours allowances until the overage charges reach the spend cap. Those allowances show cap_state: 'overage'.
summary(), spend() and estimate() carry spend_cap (0.10.0+), typed SpendCap. Amounts are in minor units
(cents) of currency:
| Field | Meaning |
|---|---|
state |
unavailable, off, paused (a plan payment is past due), within_allowance, accruing, warning (80 % of the cap or more) or reached. |
cap_minor, effective_cap_minor, max_cap_minor |
The cap you set (null if never set), the cap that applies (at most the plan price), and the plan price. |
charges_minor, remaining_minor, percent_of_cap |
Overage charged this period, billed on the next invoice, and what is left under the cap. |
lines |
Per allowance: units_over, billed_units, rate_minor, amount_minor. |
projected_reached_at |
When the charges reach the cap at this period's average use, or null. |
summary()andspend()also carryexhausted_reason: the 402 reason while starts are refused.summary()carriesgpu(0.25.0+), typedUsageGpu, on plans with GPU-hours (nullotherwise):used_hoursofincluded_hours,overage_hourspast the allowance,billed_overage_hours,cap_state(ok,warning,overage,gpu_blocked) andblocked_reason. The meter isgpu_secondsand the allowancegpu_hours(enforcementgpu_block); with overage on they are aspend_capline and aratesentry like CPU-hours. Atgpu_blockedonly the GPU stops: workspaces keep running and starting.spendPolicy()returns the settings (0.10.0+):overage_available,overage_enabled,overage_state(unavailable,off,on,paused),spend_cap_minor,spend_cap_min_minor,spend_cap_max_minor,ratesandcurrency.- An API key only reads them. An owner or billing member turns overage on and sets the cap under Usage & billing in the console, or from code with a user session (0.10.0+):
import { ShardfluxAccount } from '@shardflux/sdk';
const account = new ShardfluxAccount({ sessionToken: process.env.SHARDFLUX_SESSION_TOKEN! }); // sfu_... from a sign-in
const policy = await account.billing.spendPolicy(orgId);
if (policy.overage_available) {
// $9.00 per billing period; ifMatch turns a concurrent change into 409 version_mismatch
await account.billing.setSpendPolicy(orgId, { overageEnabled: true, spendCapMinor: 900, ifMatch: policy.version });
}
await account.billing.setSpendPolicy(orgId, { overageEnabled: false }); // always allowedsetSpendPolicy(orgId, update) takes alertThresholdsPercent, overageEnabled and spendCapMinor, all optional (give
at least one), and ifMatch (the policy version, or '*'). The cap is at least spend_cap_min_minor ($1), at most
spend_cap_max_minor (the plan price), and not below what overage already charged this period. A refusal is a
ShardfluxApiError 422 validation_failed with reason overage_unavailable, spend_cap_required,
spend_cap_below_minimum (details.min_minor), spend_cap_above_plan_price (details.max_minor) or
spend_cap_below_charges (details.charges_minor), or 409 conflict version_mismatch
(details.current_version). An API key gets 403. Every owner and billing member gets an email about the change.
Workspaces
Workspaces by key
(0.15.0+) workspace(key, params) (module-level, on a client created from SHARDFLUX_API_KEY) and
cloud.workspace(key, params) return a WorkspaceRef without a request. Its first call opens the key: the workspace is
created from params.template on first use and resumed afterwards, in one request that also returns its tool token.
Later calls go straight to the workspace, which wakes on use.
import { workspace } from '@shardflux/sdk';
const ws = workspace(`acme/${threadId}`, { template: 'default', secrets: ['GITHUB_TOKEN'] });
await ws.exec('git clone https://github.com/acme/app && cd app && npm test'); // a string runs through bash -lc
await ws.exec(['python3', '-c', 'print(42)']); // an argv array runs without a shell
await ws.files.write('/home/user/notes.txt', 'hello\n');
const tools = await ws.tools(); // agent tools, before any VM exists| Member | Meaning |
|---|---|
exec(command, options?) |
cell().exec.run(): a string runs through bash -lc, an argv array without a shell. |
files |
read, readText, readWithInfo, write, list, stat, remove, mkdir, move, search, patch, as on cell().files. |
executions |
A file-first workspace's run() and get(). |
tools(options?) |
workspaceTools() for this key, built from the API key's tool permissions (GET /v1/me, once per client) before the workspace exists; computer only with computerUse: true. The first tool call opens the key. |
open() |
The Workspace, for lifecycle calls, ports, computer use and tool-call capture. |
cell(options?) |
The workspace's cell client. |
hint(options?) |
Starts the open early (for example when the model starts a tool call); afterwards Workspace.hint(). |
key, created, mode |
created: true when this ref's open created the workspace, false when it existed, undefined before the first call. |
paramsareopen()'s withoutkey.templateis required:defaultis the platform default template.- Calls made while the first open runs share it. A failed open is not kept: the next call opens again.
create: falsenever creates: the first call finds the key's workspace and fails with 404not_foundwhen it has none.- A workspace deleted under the ref (
isWorkspaceGone(err)) fails the call that meets it; the next call opens the key again, which creates a new workspace.
Open a workspace
const workspace = await cloud.workspaces.open({
key: 'customer-42/main',
template: 'python-node-browser',
secrets: ['OPENAI_API_KEY'],
});open() creates the workspace from the template's latest published version on first use, and reconnects to or resumes
it afterwards. It never resets an existing workspace. The API holds the request until the workspace is ready (up to
20 s per request) and returns the first tool token with it, so the first tool call starts at once.
| Parameter | Type | Meaning |
|---|---|---|
key |
string |
Required. Your stable name for the workspace, 1-200 characters without control characters, for example `${customerId}/${projectId}`. Keys starting with sf: are reserved. |
template |
string |
Required. Template slug; default is the platform default template. A new workspace uses its latest published version. |
caps |
Caps: { cpu_millis?, memory_mib?, disk_gib?, allocation_mode?, memory_mib_held? } |
Optional caps. The ceiling is the lowest of the template, your cap and your plan. memory_mib is the most memory the workspace may use. (0.13.0+) memory_mib_held: what it holds while idle, elastic memory. allocation_mode (type AllocationMode) is deprecated: accepted and ignored. Given caps replace the stored ones. |
agentLabel |
string |
Attribution label for tool tokens obtained through this handle (one agent session per label). |
tools |
ToolName[] |
Tools to request in tool tokens (exec, files, pty, process, git, browser). Default: all the key permits. |
secrets |
string[] |
Secret names to bind (max 50). Sets the binding of a new key and replaces it on an existing key; omitted leaves it unchanged. |
inputs |
Record<string, string> |
(0.7.0+) The template version's text inputs. A new key stores each value (else the declared default); an existing key has them all replaced; omitted leaves them unchanged. |
lifetime |
'persistent' | 'session' |
Omitted: the template version's default, else persistent. Immutable: reopening a live key with another value is 409 lifetime_mismatch. |
mode |
'processful' | 'file_first' |
(0.9.0+) Omitted: processful for a new key, the stored mode for an existing one. file_first opens a file-first workspace, ready at once. Immutable: reopening with the other mode is 409 mode_mismatch. |
wait |
false | WaitOptions |
Default: wait until ready. false returns at once, possibly not ready. |
idempotencyKey |
string |
Default: a fresh key per call, so a transport retry replays instead of duplicating. |
admissionTimeoutMs |
number |
(0.22.0+) The client's admissionTimeoutMs for this open: how long it is sent again while every workspace the plan lets work at once is busy (429 quota_exceeded). |
onProgress |
ProgressListener |
(0.6.0+) Progress of this open. |
When the wait runs out, open() throws OperationTimeoutError carrying the operation id. The start keeps going: call
open() again or cloud.workspaces.waitForOperation(err.operationId). (0.22.0+) A start queued for capacity is
followed until its deadline first (see Start deadlines).
Look up workspaces
const page = await cloud.workspaces.list({ keyPrefix: 'customer-42/' }); // { data, nextCursor }
for await (const ws of cloud.workspaces.listAll({ keyPrefix: 'customer-42/' })) console.log(ws.key, ws.state);
const same = await cloud.workspaces.get(workspace.id);
const byKey = await cloud.workspaces.findByKey('customer-42/main'); // null when no workspace has the key| Method | Returns |
|---|---|
get(workspaceId, { agentLabel?, tools? }?) |
Workspace |
list(params?) |
Page<Workspace>: { data, nextCursor } |
listAll(params?) |
AsyncGenerator<Workspace> over every page |
findByKey(key, { includeDeleted?, agentLabel?, tools?, signal? }?) |
(0.6.0+) Workspace | null. Searches every lifetime and purpose; returns the live workspace, and a tombstone only when no live workspace has the key (includeDeleted, default true). |
inputs(workspaceId) |
(0.7.0+) Record<string, string>: the text inputs. |
operations(workspaceId, { limit?, cursor?, state?, kind? }?) |
Page<Operation>, newest first |
agentSessions(workspaceId, { limit?, cursor? }?) |
Page<AgentSession> |
list() parameters: state, desiredState, keyPrefix, includeDeleted, lifetime (persistent | session |
any, default persistent), purpose (standard | template_draft | template_test | any, default
standard), limit and cursor. Use findByKey() rather than listAll({ keyPrefix }) to look up one key.
The workspace handle
A Workspace holds the latest view from the API plus managed tool tokens and cell clients.
| Property | Meaning |
|---|---|
id, key |
Workspace id and key. |
created |
(0.15.0+) true when the open() that returned this handle created the workspace, false when it reconnected or resumed; null for handles from get(), list() and findByKey(). |
state |
Observed state: creating, starting, running, suspending, suspended, resuming, forking, stopping, failed, deleting, deleted. |
desiredState |
running, suspended or deleted. |
ready |
true when observed and desired state are both running. |
template |
Template slug and version the workspace uses, and immutable_version (0.13.0+): the version its immutable paths show, or null. |
activeOperation, pendingReason |
The operation in progress, and why a start is waiting. |
failure |
(0.33.1+) A failed workspace's reason, WorkspaceFailure: { operation_id, kind, code, message, retryable, details, failed_at }, the error of the operation that failed it (for example vm_start_failed with details.host_reason). Set until a start succeeds; null otherwise. |
grants, ceilings |
Resources granted to the running workspace, and its ceilings. |
memory, allocationMode |
(0.13.0+) WorkspaceMemory: { allocation_mode, promised_mib, held_mib, plugged_mib } (elastic memory), and 'elastic' (or 'fixed' for a workspace that holds its whole maximum). |
lifetime, purpose, diskLayout, origin, idleTimeoutSeconds, endedReason |
(0.6.0+) Session and template metadata. diskLayout is legacy or layered. |
startup |
(0.7.0+) State of the template's start commands and services: pending, running, ready or failed (with the step, exit code and output tail). Null when the version has neither. |
suspendRequest |
(0.10.0+) The pending suspend when idle request, { requested_at, after_seconds, not_before }, as of the last view; null when there is none. |
storageRecovery |
(0.30.1+) Set while the workspace's files are stored apart from its computer: { state: 'stored', reason, copy_id, files_kept: true, message }. The next resume restarts its processes from those files. Null otherwise. |
secrets |
Secret bindings: get(), set(names). |
lastTiming |
(0.6.0+) Timing of the last open, wake or waited lifecycle call made through this handle. |
grantedTools |
Tools granted by the most recent tool token. |
mode, treeRevision |
(0.9.0+) processful or file_first; a file-first workspace's newest tree revision seen by this handle (from the view, X-Tree-Revision of every cell response and execution results; it never moves back), null for a processful one. |
executions |
(0.9.0+) File-first workspaces: run() and get() (see Executions). |
data |
The raw view (GET /v1/workspaces/{id}). |
Methods: refresh(), waitUntilReady(waitOptions?), inputs(), the lifecycle calls below, wake(), hint()
(0.9.0+), cell(),
tokens(), changes(), saveAsTemplate() and captureToolCalls().
Lifecycle calls
await workspace.suspend({ wait: true }); // memory and processes are checkpointed
await workspace.resume({ wait: true }); // or open() the key again
const { workspace: copy } = await workspace.fork({ key: 'customer-42/experiment' }, { wait: true });
await copy.delete(); // tool access ends at once; the key is reusable after deletion finishes| Call | Does |
|---|---|
suspend(opts?) |
Checkpoints memory and processes and stops compute. A session workspace is refused (409 session_lifetime). (0.12.0+) { durable: true } also waits for the checkpoint's durable copy (below). |
suspendWhenIdle({ afterSeconds, idempotencyKey? }) |
(0.10.0+) Suspend when idle: suspends the workspace once it has been idle for afterSeconds (0 to 3600; 0 = as soon as it is idle), for the end of an agent turn. The next tool call or a resume cancels it; a running command, attached stream or keepalive postpones it. Returns { suspendRequest, operation, workspace }: operation is the suspend already in progress (then nothing is recorded), else null. Errors: 409 not_running, operation_in_progress, session_lifetime, workspace_deleted; 422 validation_failed outside 0..3600. |
cancelSuspendWhenIdle() |
(0.10.0+) Cancels a pending request. Idempotent, in any state; a suspend it already started is not undone. |
resume(opts?) |
Resumes a suspended workspace. Tool calls wake a suspended workspace by themselves, so this is rarely needed. |
snapshot({ label?, ...opts }?) |
Takes a snapshot. |
resize({ memoryMib?, memoryMibHeld?, allocationMode?, cpuMillis?, diskGib? }, opts?) |
(0.14.0+) Resizes the workspace, running or suspended, without a restart, and stores the new caps. Resolves once it has finished with a ResizeResult: workspaceId, operationId, state, caps, and for each resource named memory, cpu or disk with applies_at (now, resume, next_start), previous_*, target_*, applied_*, limit_reason and reason. allocationMode is deprecated: accepted and ignored. Also cloud.workspaces.resize(id, params). |
fork({ key, caps?, lifetime? }, opts?) |
Copies the workspace into a new key. Returns { operation, workspace }; the copy's handle comes back at once. lifetime is the fork's own (default persistent). |
delete(opts?) |
Tombstones the workspace at once (tool access ends) and cleans up storage in the operation. |
close(opts?) |
(0.6.0+) Ends a session workspace (see Sessions). |
reset(opts?) |
(0.6.0+) Wipes every change of a layered workspace (see Reset, save as template and changes). |
workspace.resize() also accepts atLeast?: boolean and wait?: boolean (0.16.0+). atLeast: true keeps every
named numeric resource at its current size or grows it to the bounded request. wait: false returns the held answer
and pending operation without polling. workspace.caps is the typed stored caps view; workspace.grants can be null
until the allocation has been reported.
On a file-first workspace, suspend, resume, snapshot, fork, reset and
saveAsTemplate throw NotSupportedForModeError without a request (0.9.0+).
Every call exists on cloud.workspaces too, taking the workspace id first:
cloud.workspaces.suspend(id, opts), cloud.workspaces.fork(id, target, opts),
cloud.workspaces.suspendWhenIdle(id, { afterSeconds }), and so on.
Options (LifecycleOptions): idempotencyKey, wait and onProgress. What the promise means depends on wait:
| Call | Resolves when | Returns |
|---|---|---|
await workspace.suspend() |
the suspend is requested (usually still queued; the workspace is still running) |
Operation |
await workspace.suspend({ wait: true }) (0.6.0+) |
the suspend has finished (workspace.state is then suspended) |
FinishedOperation (state succeeded) |
await workspace.suspend({ durable: true }) (0.12.0+) |
the suspend has finished and its checkpoint is in durable storage (result.durable: true) |
FinishedOperation (state succeeded) |
wait also accepts WaitOptions. A failed or canceled operation throws OperationFailedError. Running out of time
throws OperationTimeoutError; the operation continues server side.
Durable storage
(0.12.0+) A suspend returns as soon as the workspace is sealed on its host, typically in a few hundred ms.
result.durable turns true when the checkpoint lands in durable storage, typically within a few seconds
(Durable storage).
import { durabilityOf } from '@shardflux/sdk';
const op = await workspace.suspend({ durable: true });
durabilityOf(op); // { state: 'durable', checkpointId, localCommitAt, durableAt, localCommitToDurableMs, ... }| API | Meaning |
|---|---|
suspend({ durable: true }) |
Implies wait; the suspend and the copy share its timeoutMs and signal ({ durable: true, wait: { timeoutMs: 60_000 } }). |
cloud.workspaces.waitForDurable(operationOrId, waitOptions?) |
The same wait for an operation you hold, such as a finished fork of a running workspace. Resolves at once for an operation already durable. |
isDurable(op) |
result.durable; true for a succeeded suspend or fork without the field; else null. |
durabilityOf(op) |
result.durability as Durability: state (pending, durable, lost), checkpointId, generationId, localCommitAt, durableBy, durableAt, localCommitToDurableMs, overdueAt, reason; null when absent. |
lostSuspendOf(op) |
A resume's result.lost_suspend as LostSuspend (lifecycle); null when absent. |
hostLostOf(op) |
(0.13.1+) A resume's, open's or suspend's result.host_lost as HostLost (detectedAt, restoredFrom, restoredCheckpointId, stateAsOf; lifecycle); null when absent. Also lastTiming.server.hostLost. |
lastTiming.server |
durable, suspendPath, durability, lostSuspend; formatTiming() prints sealed on host, durable 435 ms later. The wait for the copy is the progress phase durable. |
Operation.result is typed with durable, suspend_path, durability and lost_suspend. The errors a durable wait
can end with are in Errors.
Suspend progress and cancel (0.26.0+)
Read a suspend's progress without waking the workspace, and cancel it while the workspace is still preparing it:
const op = await workspace.suspend(); // requested
const progress = await cloud.workspaces.suspendProgress(op.id);
if (progress.cancelable) {
await cloud.workspaces.cancelSuspend(op.id); // the workspace keeps running as it was
}| Call | Does |
|---|---|
cloud.workspaces.suspendProgress(operationId, { signal? }) |
A suspend's live progress as a SuspendProgress (below), read without waking the workspace. getOperation() of a suspend carries the same progress. |
cloud.workspaces.requestCancelSuspend(operationId, { idempotencyKey?, signal? }) |
Cancels a suspend before its capture starts. Resolves as soon as the host accepts, with a CancelSuspendResponse { operation, progress } (progress.accepted: true); the operation then turns canceled with errorCode suspend_canceled. |
cloud.workspaces.cancelSuspend(operationId, opts?) |
The same, and waits until the operation is canceled with suspend_canceled; resolves with that Operation. opts (CancelSuspendOptions) are WaitOptions plus idempotencyKey. Running out of time throws OperationTimeoutError; the cancellation continues. |
Once capture has started the suspend completes: both cancel calls throw ShardfluxApiError 409 conflict with
reason: 'suspend_capture_started' (details.progress has the progress). Wait for the operation, then resume the
workspace. See Errors.
SuspendProgress |
Meaning |
|---|---|
operation_id, state, workspace_state |
The suspend, its operation state and the workspace's state. |
phase |
Where the suspend is, for example preparing_snapshot or capturing. |
phase_started_at, deadline_at |
When the phase began and when the host finishes it at the latest, when reported. |
cancelable |
true while the suspend can still be canceled (phase: 'preparing_snapshot'). |
supported |
Whether the phase is reported live for this suspend. |
safe_action |
The next step: wait_for_terminal (wait for the operation), resume (it is suspended), retry_tools (the workspace is running: carry on), inspect_failure (read the operation's error). |
accepted |
On a cancel: the host accepted it. |
Waiting for operations
const op = await workspace.suspend(); // requested
await cloud.workspaces.waitForOperation(op.id, { timeoutMs: 60_000 });waitForOperation(id, opts?) asks the API to hold each poll until the operation changes (Prefer: wait, at most
20 s per request), so completion arrives within one round trip. Against a server that does not hold polls it backs off
from 250 ms, doubling to 5 s with ±20 % jitter. getOperation(id) reads an operation once; lifecycle operations stay
readable after a workspace is deleted.
WaitOptions |
Default | Meaning |
|---|---|---|
timeoutMs |
300000 |
Give up waiting after this long. The operation continues. |
pollIntervalMs |
250 |
First backoff delay when the server does not hold polls. |
maxPollIntervalMs |
5000 |
Longest backoff delay. |
serverWait |
true |
Ask the server to hold each poll. |
signal |
none | Abort the wait. |
onProgress |
none | Progress events while waiting. |
untilCapacityDeadline |
true |
(0.22.0+) When timeoutMs passes while the operation is capacity_pending with its deadline still ahead, keep waiting until that deadline (plus 5 s). false throws OperationTimeoutError at timeoutMs. signal ends the wait either way. |
Operation states: queued, capacity_pending, running, succeeded, failed, canceled.
Start deadlines
A queued open, resume or fork (capacity_pending) has a deadline 15 minutes after it was created, moved later while
new capacity is starting for it (start deadlines). Its deadline is
error.details.deadline_at on the operation, deadlineAt on the capacity_pending progress event (0.6.2+), and
OperationTimeoutError.deadlineAt when your wait ends first.
(0.22.0+) A wait whose timeoutMs passes while its start is queued keeps waiting until the start's deadline, so
open(), resume({ wait }), fork(..., { wait }), wake() and waitForOperation() see a queued start through.
Pass untilCapacityDeadline: false (in WaitOptions or WakeOptions) to stop at timeoutMs; signal ends the wait
either way. While it waits, the capacity_pending progress event carries queuePosition, scaleOut (requested
while new capacity is starting), expectedReadyBy and deadlineAt, and comes again when one of them changes.
capacityOf(operation) reads the same fields from an operation, and OperationTimeoutError.queuePosition has the
last position.
A start still pending at the deadline fails with capacity_unavailable: nothing was started, and a suspended
workspace stays suspended. OperationFailedError.retryable (0.6.2+) is true for it. The SDK does not retry it
for you.
import { OperationFailedError } from '@shardflux/sdk';
try {
await workspace.resume({ wait: true });
} catch (err) {
if (err instanceof OperationFailedError && err.retryable) {
console.warn(`${err.errorCode}: nothing changed; retry`);
} else {
throw err;
}
}Sessions
(0.6.0+) A session workspace is discarded when its session ends: on close(), or after it has been idle for the
idle timeout (10 minutes unless the template sets one). The key then opens a new, empty workspace with a new id.
const job = await cloud.workspaces.open({ key: 'job-1234', template: 'python-node-browser', lifetime: 'session' });
try {
await job.cell().exec.run(['python3', '-c', 'print("work")']);
} finally {
await job.close(); // session: ends it and returns the delete operation; persistent: no request, returns null
}close()is safe infinallyfor any workspace. It always aborts the handle's local streams (exec output, PTY reads, in-flight cell requests); commands keep running. Only for a session does it callPOST /v1/workspaces/{id}/close.- Disconnecting, a closed WebSocket or an expired token never ends a session.
- A session cannot be suspended (409
session_lifetime). Fork it to keep its state. cloud.workspaces.close(id)on a persistent workspace is 409not_session.
Reset, save as template and changes
(0.6.0+) These need a workspace whose diskLayout is layered; a legacy workspace is refused with 409
legacy_disk_layout.
await workspace.reset({ wait: true }); // wipes every change; the previous state stays restorable for 7 days
const { build } = await workspace.saveAsTemplate({ templateSlug: 'acme-dev', description: 'deps installed' });
await cloud.templates.builds.waitForBuild(build.organization_id, build.id);
const changes = await workspace.changes({ pathPrefix: '/home/user', summary: true });reset()keeps the key, id, template version, caps, secret bindings and volume attachments. A running workspace restarts on a blank layer; a suspended one stays suspended and boots blank on its next resume.saveAsTemplate({ templateSlug, displayName?, description?, defaults?, settings?, checkpointId?, autoPublish?, acknowledgedScanFindings?, idempotencyKey? })saves the workspace as the next version of an organization template. It returns{ operation, build }; follow the build withtemplates.builds.waitForBuild().changes({ pathPrefix?, limit?, cursor?, hash?, summary? })lists changes against the template:added,modified,metadata(withhash),deleted,replaced. It is served by the running workspace and needs thefilestool.workspace.cell().changesAll()follows every page.
Inbound ports (0.14.0+)
workspace.ports (by id: cloud.workspaces.ports(id)) serves a TCP port of the workspace at its own private HTTPS
URL (guide):
const { url } = await ws.ports.expose(3000); // { port, url, createdAt, callback, created }
const { token } = await ws.ports.token(3000, { ttlSeconds: 3600 }); // { token: 'sfp_…', expiresAt, url }
const link = await ws.ports.link(3000, { path: '/dashboard' }); // { url, expiresAt }: open in a browser
const hook = await ws.ports.createCallbackUrl(3000); // { url, createdAt }: shown onceexpose(port)is idempotent (createdis false when the port already was exposed);list()returnsExposedPort[]ordered by port;close(port)stops its tokens, links and callback URL at once.token(port, { ttlSeconds })(60-86400, default 3600): send it asAuthorization: Bearer <token>, or asX-Shardflux-Tokenwhen the app readsAuthorizationitself.link(port, { ttlSeconds, path })(60-604800, default 86400).createCallbackUrl(port)creates or replaces the callback URL;revokeCallbackUrl(port)removes it.- Refusals:
inbound_ports_not_available,port_not_exposed,port_limit(errors).
Timing and progress
(0.6.0+) Every open, wake and waited lifecycle call is traced. workspace.lastTiming (and err.timing when the call
fails) is a LifecycleTiming; formatTiming() prints it:
resume 413 ms, succeeded (workspace 01a0ead6-92e5-7d19-ab7a-f565bf4676cb, operation 01a0ead6-bd61-737a-ae88-66e2d01a6e25)
client: request 218 ms → queued 195 ms
server: queued 51 ms, ran 290 ms, total 341 ms; resume from local_cache, boot to ready 211 ms, host disk 0 ms, load 6 ms, ready 62 ms, total 211 ms, after_restore 36 ms
outside the server: 72 ms- client: phases on your monotonic clock: the request (
heldwhen the server held it), each operation state observed while waiting with the server's reason, then the view read and the first tool token (together,∥). - server: the operation's own timing:
queueduntil it began running (including any wait for capacity),ranfor the work, then the start or resume path the cell reported. Host metrics print in their own units (0.31.0+): a…_usspan in µs or ms, a…_msspan or a host phase in ms, counters and flags as plain numbers;server.hostTimingsMskeeps the raw values. - outside the server: your total minus the operation's (network, TLS, polling, view and token).
onProgress (on the client, or per call) receives ProgressEvents:
type |
Carries |
|---|---|
phase |
action, phase (request, queued, capacity_pending, running, view, token, busy, capture_flush, durable (0.12.0+)), reason, atMs, and on capacity_pending deadlineAt (0.6.2+), queuePosition, scaleOut and expectedReadyBy (0.22.0+) |
retry |
retry: { request, attempt, cause, delayMs }. (0.22.0+) A call refused before it ran adds admission: true, reason (host_capacity or concurrent_workspaces), queuePosition, retryAfterSeconds and waitedMs |
done |
timing: the call's LifecycleTiming |
action is open, suspend, resume, snapshot, fork, delete, close, reset, wake, wait or token, or
tool for events of tool calls (busy waits, replaced tokens, retries). A listener that throws never breaks the call.
Cell API
workspace.cell(options?) returns a CellClient for the workspace's tools, served by the workspace's cell gateway. It
obtains short-lived tool tokens from the API, reuses them until shortly before they expire, and gets a new one when
the workspace moves or resumes (409 stale_epoch or 401).
cell() option |
Default | Meaning |
|---|---|---|
agentLabel, tools |
the handle's | Attribution label and tool set of the tokens. One client per label, tool set and transition settings. |
transitionTimeoutMs |
120000 |
(0.6.0+) Total time one call spends waiting for lifecycle transitions: workspace_busy waits plus wakes (at most 3). |
wake |
workspace.wake() |
(0.6.0+) null returns workspace_not_running instead of waking. |
timeoutMs |
60000 |
Timeout of one cell request. |
maxRetries |
2 |
Retries of idempotent calls after a transient failure. |
admissionTimeoutMs |
the client's (120000) |
(0.22.0+) How long a call refused before it ran (503 host_capacity, 429 quota_exceeded) is sent again, exec starts, PTY opens and writes included. |
onProgress |
none | Tool token fetches, busy waits, retries and wakes. |
Wake on use
(0.6.0+) A tool call on a suspended workspace resumes it (or joins the resume or open already running), then runs. From 0.12.0, auto-wake can also recover a failed workspace using the same ID and disk. A call made during a suspend or resume waits for the transition. The call never runs twice: the cell executes nothing it refused.
- A resume or open still pending when
transitionTimeoutMsruns out throwsOperationTimeoutError(0.22.0+: one queued for capacity is followed until its deadline first); a failed one throwsOperationFailedError. - Following an exec's output never wakes a workspace, so an explicit
suspend()is respected. workspace.wake({ timeoutMs?, signal?, onProgress?, agentLabel?, tools? })does the same on demand. It resolvestruewhen it resumed or waited,falsewhen the workspace was already running.agentLabelandtools(0.9.0+) choose the tool token the wake brings back.- (0.9.0+) The wake is one request: a resume with
Prefer: waitand the client's agent label and tools, answered once the workspace runs with the view and a tool token, so the refused call is retried at once.resume({ wait })sends the same request (serverWait: falsekeeps the polled path). Its timing is onerequestphase with reasonheld. Against an API without the held resume, the SDK waits for the operation and fetches a token, as before. - (0.9.0+)
read(),readText(),readWithInfo(),stat(),list()andsearch()of a suspended workspace are answered from its disk while a host still holds it, without waking it (servedFrom/served_from: 'disk'). Any other call wakes it.
Wake hint (0.9.0+). A running workspace that nobody uses is
parked by its host and woken by the next tool call.
workspace.hint(opts?) tells the host a call is coming, so the wake starts earlier. The agent tools of
workspaceTools() send it when each call starts; call it yourself to start earlier, when your model starts writing a
tool call. It returns at once with { residency, wake }: residency is what the host found (resident,
frozen, hibernated, restoring; null when the workspace was not running), and for a suspended workspace wake
is the resume started in the background (a promise shared by concurrent hints; nothing needs to await it). Options:
agentLabel, tools, wake (null only reports), wakeTimeoutMs (120 000), signal. It is never retried and
never waits out workspace_busy. cell.wakeHint() is the bare request.
void workspace.hint().catch(() => {}); // fire and forget, as the model starts a tool callExec
const cell = workspace.cell();
// argv runs without a shell; use ['bash', '-lc', '...'] for shell syntax.
const run = await cell.exec.run(['bash', '-lc', 'pip install requests && python3 app.py'], {
cwd: '/home/user/project',
env: { DEBUG: '1' },
timeoutMs: 10 * 60_000,
onOutput: (stream, chunk) => process[stream].write(chunk),
});exec.run(argv, options?) starts the command and collects its output until it exits. If the output stream drops, it
reconnects from the byte offsets it already processed; it never starts the command twice. Aborting signal also
cancels the command in the workspace.
RunOptions |
Default | Meaning |
|---|---|---|
sessionId |
generated | Session id. Starting an existing session returns it and never runs anything again. |
cwd, env, user |
Working directory, an absolute path (default /home/user; the API refuses a relative one with 422 invalid_cwd), extra environment, guest user. |
|
stdin |
Written to stdin, which is then closed (max 1 MiB). | |
timeoutMs |
Kill the command after this long. | |
killGraceMs |
5000 |
Time between SIGTERM and SIGKILL when timeoutMs ends the command (up to 600000). |
maxOutputBytes |
1048576 |
Bytes of stdout and of stderr kept in memory; the rest is counted, not kept. |
onOutput |
(stream, chunk) for output as it arrives. |
|
signal |
Abort the call. | |
cancelOnAbort |
true |
When signal aborts, also cancel the command (SIGTERM, then SIGKILL after the grace). |
maxReconnects |
10 |
Output reconnect attempts after a dropped stream. |
secretRefs |
Extra secret names injected into this process only (see Secrets). | |
resourceHint |
'auto' |
(0.14.0+) 'heavy' gives an elastic workspace its memory before the command starts, 'light' starts it at once, 'auto' decides from the command. A workspace that sees its maximum from boot already has it. |
RunResult: sessionId, exitCode, termSignal, timedOut, canceled, stdout, stderr, stdoutBytes,
stderrBytes, truncated, session and reconnects, and (0.13.0+) memoryGrow: the grow the command's start
waited for on a workspace with elastic memory (MemoryGrow: outcome,
from_mib, want_mib, got_mib, deliver_ms), else null.
(0.10.0+) A command that could not start (a cwd that is not a directory, a program that is not on PATH, an
unknown user) rejects with ExecStartError: nothing ran, so there is no exit code. Its message is the workspace's
reason, for example The command could not start: working directory "/home/user/app" is not a directory.
import { ExecStartError } from '@shardflux/sdk';
try {
await cell.exec.run(['python3', 'main.py'], { cwd: '/home/user/app' });
} catch (err) {
if (!(err instanceof ExecStartError)) throw err;
console.error(err.message, err.sessionId);
}The lower-level calls work on sessions by id: exec.start(request) (idempotent by session_id), exec.get(id),
exec.output(id, { stdoutOffset?, stderrOffset?, follow?, signal? }) (resolves to an async generator of output events),
exec.signal(id, signal, onlyLeader?) and exec.cancel(id, graceMs?) (SIGTERM, then SIGKILL after graceMs: 0 to
60000, default 5000; resolves once the command has ended).
Files
await cell.files.write('/home/user/data.bin', new Uint8Array([1, 2, 3]));
const bytes = await cell.files.read('/home/user/data.bin');
const listing = await cell.files.list('/home/user');
await cell.files.remove('/home/user/data.bin');| Method | Returns |
|---|---|
read(path, { offset?, length? }?) |
Uint8Array. Without length the whole file, continued across reads. |
readText(path, { offset?, length? }?) |
string (UTF-8) |
readWithInfo(path, { offset?, length? }?) |
(0.9.0+) { data, size, revision, servedFrom }: the bytes plus X-File-Size, X-File-Revision (files up to 16 MiB) and X-Served-From. |
write(path, data, { mode?, createParents?, append?, idempotencyKey?, ifTreeRevision? }?) |
{ path, bytes_written, sha256, durable, revision }. Atomic replace or append, acknowledged after the file and its directory are fsynced. A random idempotency key makes a retried upload a no-op. |
remove(path, { recursive? }?) |
void |
stat(path, { revision? }?) |
FileInfo: path, name, type, size, mode, modified_at; with revision: true (0.9.0+) also revision, the SHA-256 of the content (regular files up to 256 MiB). |
list(path, { limit? }?) |
{ entries, truncated } |
mkdir(path, { parents?, mode? }?) |
FileInfo |
move(from, to, { overwrite? }?) |
FileInfo |
search(path, pattern, opts?) |
(0.9.0+) { matches, truncated, stop_reason, files_scanned, served_from }. Options: regex, caseInsensitive, include, exclude, maxMatches (default 200, at most 5000), maxFileBytes (default 1 MiB), contextLines (0-5), signal. |
patch({ path, edits | content, expectedRevision?, createParents?, mode? }, { idempotencyKey?, ifTreeRevision?, signal? }?) |
(0.9.0+) { path, revision, previous_revision, bytes_written, durable, replacements, file }. edits are { oldText, newText, replaceAll? }, each matching exactly once unless replaceAll, all or none. Always sends an Idempotency-Key. |
remove, mkdir and move also take ifTreeRevision (0.9.0+): on a file-first workspace the call applies only at
that tree revision, else TreeRevisionMismatchError. Search and edit files explains search, patches,
revisions and their refusals.
Executions
(0.9.0+) On a file-first workspace, workspace.executions (also cell.executions) runs
each command in a fresh VM on the workspace's files:
const r = await workspace.executions.run(['bash', '-lc', 'npm test'], { cwd: '/home/user/app', timeoutMs: 600_000 });
if (r.state !== 'succeeded') throw new Error(`${r.state}: ${r.errorReason}`); // nothing was published
console.log(r.exitCode, r.stdoutText, r.changed, r.treeRevision);ExecutionRunOptions |
Default | Meaning |
|---|---|---|
executionId |
a fresh ex-<uuid> |
The idempotency key (8-128 characters of A-Z a-z 0-9 . _ : -). The same id with the same request returns the recorded result (replayed); with another request, 409 execution_id_reused. |
cwd, env, user, stdin, timeoutMs, killGraceMs, secretRefs |
As for exec.run (cwd under /home/user). |
|
outputLimitBytes |
1 MiB | stdout and stderr are each kept up to this many bytes (at most 16 MiB). |
maxRetries |
5 |
Retries with the same id after network failures and retryable 429/5xx answers such as 503 no_execution_host (Retry-After honoured up to 30 s). |
attemptTimeoutMs |
300000 |
Longest single wait for the answer; then the request is sent again with the same id and joins the running execution (not counted as a failure). |
signal |
Stops waiting. The execution continues; get() returns its result later. |
ExecutionResult: executionId, state (succeeded, failed, lost), exitCode, termSignal, timedOut,
stdout and stderr (bytes) with stdoutText, stderrText and text(stream), stdoutTruncated,
stderrTruncated, baseRevision, treeRevision, changed ({ path, change, type }), changedTruncated,
timings, error and errorReason, replayed, ok and raw. A failed or lost result is returned, never
thrown, and never retried with a new id.
executions.get(id, { waitMs?, signal? }) reads an execution: its result, or a pending one (state queued or
running) while it runs; waitMs polls until it ended or the time passed. Results are kept for 7 days.
newExecutionId() makes an id. On a processful workspace executions and ifTreeRevision throw
NotSupportedForModeError without a request.
Processes
cell.processes.list() returns the guest's processes; cell.processes.signal(pid, signal) signals one.
Terminals
| Method | Does |
|---|---|
pty.open({ session_id?, argv?, env?, cwd?, user?, rows?, cols?, secret_refs? }?) |
Opens a PTY session (default 24 rows, 80 columns; idempotent by session_id). |
pty.get(id), pty.close(id) |
Status; close (SIGHUP, then SIGKILL after a grace). |
pty.input(id, data), pty.resize(id, rows, cols) |
Write input; resize. |
pty.read(id, { offset?, quietMs?, timeoutMs?, maxBytes? }?) |
Reads output from offset over the attach WebSocket until quietMs (500) without output or timeoutMs (5000) in total. Returns { output, nextOffset, session, exited }. |
pty.attachUrl(id, offset?) |
{ url, token } for your own WebSocket client (send the token as Authorization: Bearer). |
Git
git.clone({ url, path, branch?, depth?, timeout_ms? }) (HTTPS remotes only), git.status(path) and
git.commit({ path, message, all?, paths?, author_name?, author_email? }) (all stages every change, default true).
Results carry exit_code, stdout, stderr and, for a commit, commit.
Browser
browser.screenshot({ url, width?, height?, timeout_ms? }) returns PNG bytes (default 1280×800).
browser.content({ url, format?, timeout_ms? }) returns { url, format, content, truncated }; format is html
(default) or text. Both navigate the workspace's headless Chromium.
Closing a cell client
cell.close() aborts every in-flight and future request of that client, including exec output streams and PTY reads.
Commands keep running in the workspace. workspace.close() closes the handle's clients.
Computer use (0.15.0+)
A desktop in the workspace that agents drive with screenshots, clicks and keystrokes; see the computer use guide.
| Call | Does |
|---|---|
cloud.workspaces.open({ ..., computerUse: true }) |
Opens with computer use on (false off, null follows the template). |
workspace.setComputerUse(true | false | null), workspace.computerUse |
The workspace's switch; { enabled, workspace, template, available }. |
cloud.templates.setComputerUse(slug, enabled, { organizationId? }) |
The switch for every workspace of one of your templates. |
workspace.computer.act(actions, { screenshot?, settleMs?, format?, quality? }) |
Runs a batch of Claude's computer toolset actions in order: { results, screenshot?, cursor, display }. |
workspace.computer.screenshot() |
{ format, width, height, data } (bytes). |
workspace.computer.status(), .start({ width?, height? }), .stop() |
The desktop itself. |
workspace.computer.stream({ interactive?, ttlSeconds? }), .stopStream() |
A private link to the live view: { url, expiresAt, port, interactive }. |
workspace.computer.frames({ maxFps?, format?, quality?, signal? }) |
ComputerFrames: the screen as images, an async iterable of { seq, format, width, height, capturedAt, data }, one per change; close() ends it. cell.computer.framesUrl() gives its WebSocket. |
computerToolset(workspace) |
{ definition, run(content) } for Claude's computer_toolset_20260801: one batch per model turn, a tool_result per call. |
Agent tools
workspaceTools(workspace, options?) returns framework-neutral tools: a name, a description, a JSON Schema for the
parameters, the tool permission it needs, and execute(args, { signal?, toolCallId? }). It takes a Workspace or
(0.15.0+) a WorkspaceRef; await workspace(key, params).tools() builds them before the workspace exists, and the
model's first tool call creates it. Export them for your model provider and dispatch its tool calls:
import { executeToolCall, toAnthropicTools, workspaceTools } from '@shardflux/sdk';
const tools = workspaceTools(workspace);
const anthropicTools = toAnthropicTools(tools); // or toOpenAITools(tools, { api: 'chat' | 'responses' })
// For each tool call the model makes:
const output = await executeToolCall(tools, { name: 'exec', input: { command: 'ls -la' }, id: 'toolu_01' });| Tool | Permission | Arguments (required in bold) |
|---|---|---|
exec |
exec |
command (run with bash -lc), cwd (absolute; a relative one is refused with invalid_cwd), timeout_ms (1000-86400000 on a processful workspace (0.13.0+), 1000-3600000 on a file-first one; default 600000), stdin, background (0.13.0+) |
exec_read |
exec |
(0.13.0+) session_id, stdout_offset, stderr_offset, wait_ms (0-60000) |
exec_cancel |
exec |
(0.13.0+) session_id, grace_ms (1-60000, default 5000) |
read_file |
files |
path, offset, length |
write_file |
files |
path, content, append, create_parents (default true) |
list_files |
files |
path, limit (1-10000) |
search_files |
files |
(0.9.0+) path, pattern, regex, case_insensitive, include, exclude, max_matches (1-5000), context_lines (0-5) |
edit_file |
files |
(0.9.0+) path, edits (1-100 of { old_text, new_text, replace_all }), expected_revision |
list_processes |
process |
none |
signal_process |
process |
pid, signal |
terminal_open |
pty |
command, rows, cols |
terminal_send |
pty |
session_id, input (include \n to press Enter) |
terminal_read |
pty |
session_id, offset, wait_ms (0-60000) |
terminal_close |
pty |
session_id |
git_clone |
git |
url (https), path, branch, depth |
git_status |
git |
path |
git_commit |
git |
path, message (stages every change) |
browser_screenshot |
browser |
url, width, height. Returns a base64 PNG. |
browser_content |
browser |
url, format (text or html) |
computer (0.15.0+) |
computer |
action (a computer toolset member), coordinate, start_coordinate, region, text, scroll_direction, scroll_amount, duration, repeat; screenshot, settle_ms, format, quality. Returns ok, output, error, cursor, display, screenshot (mime_type, width, height, data_base64). |
computer_batch (0.15.0+) |
computer |
actions (1-50 objects with the computer tool's action parameters); screenshot, settle_ms, format, quality. Returns ok, results (per action: ok, skipped, output, error, took_ms, image), cursor, display, screenshot. |
WorkspaceToolsOptions |
Default | Meaning |
|---|---|---|
tools |
the last token's tools, else all | Tool permissions to expose. |
agentLabel |
the handle's | Attribution label for the tools' tokens. |
prefix |
none | Prefix for tool names, for example workspace_. |
maxOutputBytes |
65536 |
Bytes of command output or file content returned to the model. |
defaultCwd |
the guest user's home | Working directory for exec when the model gives none. |
wake, transitionTimeoutMs |
as cell() |
Wake on use for the tools' calls. |
hint |
true |
(0.9.0+) Send workspace.hint() when each call starts, without waiting for it (not for read_file, list_files and search_files). |
mode |
the workspace's | (0.9.0+) Build the definitions for this mode without reading the workspace. |
onExecution |
none | (0.9.0+) Called with each execution id of a file-first workspace's exec before it is sent. |
(0.13.0+) On a processful workspace, exec with background: true starts the command as a session of its own and
returns { session_id, state } at once (error too when it could not start). Its timeout_ms is the command's own
limit and is not set unless given; the workspace stays awake until the command ends or that time passes.
exec_read returns state, exit_code (null while it runs), term_signal, timed_out, canceled, stdout,
stderr, stdout_offset and stderr_offset (where the returned text begins), next_stdout_offset,
next_stderr_offset and truncated, plus error and burst when the session has them. Without offsets it returns
the last maxOutputBytes of each stream; the text never splits a UTF-8 character. exec_cancel sends SIGTERM, then
SIGKILL after grace_ms, and returns { session_id, state, exit_code, canceled }. background together with
burst: 'always' is refused with ToolArgumentError (a burst holds the workspace until it applies its changes), and
a file-first workspace gets neither exec_read nor exec_cancel. See Long-running commands.
(0.9.0+) search_files and edit_file (permission files) join the tools: see
Search and edit files. For a file-first workspace the tools
are exec and the files tools only, and exec runs an execution: its result adds execution_id, state,
tree_revision, changed (up to 200) and changed_truncated. Breaking (0.9.0): workspaceTools() reads
workspace.mode while building the definitions unless mode is given.
(0.8.0+) toAnthropicTools returns AnthropicToolDefinition[], assignable to Anthropic.Tool[];
toOpenAITools(tools) returns OpenAIChatToolDefinition[] (Chat Completions, assignable to
OpenAI.Chat.ChatCompletionTool[]) and toOpenAITools(tools, { api: 'responses' }) returns
OpenAIResponsesToolDefinition[] (assignable to OpenAI.Responses.FunctionTool[]). JsonSchema is a type alias, so
the schemas fit the providers' schema types; none of them needs a cast under strict.
executeToolCall(tools, call, { signal? }) accepts arguments as a JSON string (OpenAI) or an object, or input
(Anthropic; typed unknown (0.8.0+), so a tool_use block is passed as it is). It passes the call's id or call_id to execute as toolCallId (0.7.0+). It throws for an unknown
tool, and ToolArgumentError (tool, issues) for invalid arguments. validateArgs(schema, value) runs the same
check and returns the problems. See Agent tools.
Tool-call capture
(0.7.0+) Your harness's own tools (web search, SQL, HTTP APIs, MCP servers) run in your application, so their
results reach the model but not the workspace. workspace.captureToolCalls(options?) saves every call's input and
full output as files in the workspace, where the agent can process them with jq or Python, and where snapshots and
forks keep them.
const capture = workspace.captureToolCalls();
const myTools: Record<string, (input: unknown) => Promise<unknown>> = {
web_search: async (input) => ({ query: input, results: [] }),
};
const block = { type: 'tool_use', id: 'toolu_01', name: 'web_search', input: { query: 'weather oslo' } };
const output = await capture.run(block, () => myTools[block.name]!(block.input)); // returns exactly what the tool returned
await capture.flush();Capture is invisible to the harness: a wrapped tool returns the same value, the same promise object and the same thrown
error, and a synchronous tool stays synchronous. Nothing capture does throws into your code; write failures and drops
go to onError and capture.stats.
| Harness | Integration |
|---|---|
| Hand-rolled loop | capture.run(call, fn), capture.wrap(name, fn), or capture.record({ tool, input, output, callId }) |
| Shardflux tools | executeToolCall(capture.tools(workspaceTools(workspace)), call) |
| Vercel AI SDK 7 | generateText({ tools: capture.aiSdk.tools(tools) }), or ...capture.aiSdk.callbacks() |
| Mastra | new Agent({ tools: capture.mastra.tools({ ... }), hooks: capture.mastra.hooks() }) |
| Anthropic tool runner | client.beta.messages.toolRunner({ tools: capture.anthropic.tools([...]) }) |
| OpenAI Agents JS | capture.openaiAgents.attach(runner), or tools: capture.openaiAgents.tools([...]) |
| Claude Agent SDK | query({ prompt, options: { hooks: capture.claude.hooks(myHooks) } }) |
| LangChain.js / LangGraph.js | agent.invoke(input, { callbacks: [capture.langchain.handler()] }) |
| MCP client | const release = capture.mcp.instrument(client, { server: 'github' }) |
For tools defined at import time in a multi-tenant server, wrap them with captureTool(name, fn) and run each request
inside capture.activate(() => ...).
Selection. Explicit capture (record, run, wrap, tools, captureTool) always records. Hook-level adapters
(callbacks(), hooks(), attach(), handler(), instrument()) see every tool, Shardflux's own included, filtered by
include / exclude (names, a RegExp, or (tool, source) => boolean). A capture records each call id once (the last
10 000), so a wrapper and a hook can be combined.
Layout in the workspace. dir defaults to /home/user/tool-calls:
<dir>/README.md layout and jq recipes, for the agent
<dir>/<run>/index.jsonl one JSON line per call: seq, call_id, tool, status, input, output_path, ...
<dir>/<run>/000007-web_search.json the output (.json, .txt, .html, .png, .pdf, ... from its content)
<dir>/<run>/000010-github.search/ an MCP result or content blocks: part-1.txt, part-2.png, result.json
<dir>/<run>/000011-sql.input.json an input over 64 KiB<run> is capture.runId; capture.runDir is the full path. Read the index with
jq -cR 'fromjson? // empty' <dir>/*/index.jsonl, which skips a line torn by a failed append. capture.promptHint()
returns a paragraph you can add to your system prompt; capture never injects it.
Read-your-writes. Calls through the same client first wait for capture writes recorded before them, bounded by
settleTimeoutMs (30 s): exec and files calls through workspace.cell(), workspaceTools, snapshot, fork,
suspend, saveAsTemplate and close. delete and reset drop pending writes. A write to a suspended workspace
wakes it (wake: null opts out).
Serverless. Writes finish in the background: waitUntil(capture.flush()) on Vercel, or await capture.flush()
before returning on AWS Lambda. flush() and close() never reject; they return
{ complete, written, failed, dropped }.
ToolCallCaptureOptions |
Default | Meaning |
|---|---|---|
dir |
/home/user/tool-calls |
Absolute directory in the workspace. |
include, exclude |
none | Tool selection for hook-level adapters. |
transform |
none | Receives a copy of each call; returns it (changed or not) or null to drop it. If it throws, the call is dropped, never written unredacted. Nothing is redacted by default. |
maxOutputBytes |
32 MiB | Text and JSON over it are cut (.part, truncated: true); binary is not stored (dropped: "too_large"). |
maxInlineInputBytes |
64 KiB | Larger inputs go to <seq>-<tool>.input.json. |
maxPendingBytes |
128 MiB | Bytes held for pending calls; over it the output is dropped (dropped: "queue_full"). |
maxPendingCalls |
10000 |
Past it a call is not recorded at all. |
concurrency |
4 |
Parallel file writes. |
retryWindowMs |
120000 |
How long a write is retried before it is dropped (write_failed). |
settleTimeoutMs |
30000 |
Bound on flush(), close() and the read-your-writes wait. |
metadata |
none | Merged into every index line's meta. |
onError |
none | Receives a CaptureError (kind: write, queue_full, too_large, serialize, transform, gone, discarded, timeout). |
The default limits handle about 100 calls per second per workspace; raise maxPendingBytes and maxPendingCalls
for more. Response, ReadableStream, Node
streams and Blob results are never read (meta.note: "stream_not_captured").
Templates
See Templates and Build a template.
List and inspect
| Method | Returns |
|---|---|
templates.list({ includeArchived?, owner?, limit?, cursor? }?), listAll(...) |
Templates the key's organization can use (platform and its own), with the version open picks. |
templates.get(slug, { includeArchived?, owner? }?) |
One template: versions (with settings (0.7.0+) and immutable (0.13.0+): { paths, bytes } or null), compatibility, caps, installed tools, what open resolves to. find() returns null on 404. |
templates.files(slug, version, { path?, limit?, cursor?, owner? }?), filesAll(...) |
(0.6.0+) One directory level of a version's file tree. |
templates.fileEntry(slug, version, path) |
(0.6.0+) One entry. |
templates.diff(slug, { from, to, pathPrefix?, change?, limit?, cursor? }), diffAll(...) |
(0.6.0+) Diff between two versions (from may be 'base'); the first page carries summary. |
templates.versions.recipe(slug, version) |
(0.7.0+) The recipe and settings a version was built from, ready to build again. |
templates.languages(base) |
(0.7.0+) Languages and versions a base (<slug>@<version>) offers build.languages. |
templates.packages.search(ecosystem, query, { base?, limit? }?), packages.get(ecosystem, name) |
(0.7.0+) apt, pip or npm package names. apt needs base. |
owner: 'platform' picks the platform template when an organization template shadows its slug. Versions published
before file lists answer 409 file_list_unavailable; a version still being indexed answers file_list_indexing
(retryable).
Build from template.yaml
(0.7.0+) template.yaml is a recipe v2: a base, what the build adds (languages, packages, files, build steps) and
the settings a workspace gets when it opens (environment, inputs, start commands, services, defaults). A file entry may
name a local from path, relative to the file: a folder is uploaded as a tar, a file as it is.
base: ubuntu-24.04@1
build:
languages: [{ id: python }, { id: node, version: "22" }]
packages:
apt: [jq]
pip: { packages: [pandas==2.3.2] }
files:
- { from: ./app, to: /home/user/app, owner: user }
steps:
- { name: install, run: npm ci, user: user, cwd: /home/user/app }
settings:
env: { APP_ENV: development }
inputs:
PROJECT_NAME: { kind: text, required: true }
start: [{ name: seed, when: create, run: python seed.py, user: user, cwd: /home/user/app }]
services:
web: { run: npm start, user: user, cwd: /home/user/app, ready: { port: 3000 } }const { build, uploads } = await cloud.templates.buildFromFile('acme/template.yaml', {
templateSlug: 'acme-dev',
autoPublish: false, // register it unpublished, test it, publish later
wait: true, // until registered or failed; default: return the queued build
onProgress: (e) => console.log(e.type),
});
console.log(build.state, build.template_version, build.provenance.recipe_sha256, uploads.length);buildFromFile option |
Meaning |
|---|---|
templateSlug |
Required. Organization template to build into (created by the first build). |
displayName, description |
Template name when the build creates it; version description. |
autoPublish |
Publish once registered (API default true). false leaves it for test instances. |
acknowledgedScanFindings |
Up to 200 absolute paths the credential scan may report without failing the build. |
wait |
true (30 minutes) or WaitForBuildOptions. Default: return the queued build. |
onProgress |
pack, upload and build events. |
root |
Refuse every local path that resolves outside this directory. |
parseYaml |
Your own YAML parser (default: the optional yaml package). |
organizationId, idempotencyKey, signal |
immutable (top level, optional) lists directories every workspace shows read-only from the template's newest
published version (Immutable paths). The build's immutable_paths
(0.13.0+) is the list the new version declares.
buildFromFile is Node only. Folders are packed as a reproducible tar (sorted, mtime 0, no owner names; symlinks must
stay inside the folder), the same bytes the Python SDK packs, so the same inputs give the same recipe_sha256. Bytes
the organization already has are not uploaded again. It throws TemplateFileError before any request for a file it
cannot read or pack, and TemplateUploadError (status, code) when the storage refuses the bytes.
templates.buildFromRecipe(doc, { templateSlug, baseDir, ... }) builds a document already in memory.
The pieces on their own:
| Method | Does |
|---|---|
templates.uploads.put(data, { kind, sha256?, size? }) |
Uploads bytes (Uint8Array, ArrayBuffer, Blob or a stream) unless the organization has them; returns { upload, ref, uploaded }. ref is sha256:<hex> for a recipe file entry. |
templates.uploads.putPath(path) |
Node: a file, or a folder as the tar. |
templates.builds.create(organizationId, { templateSlug, recipe, ... }) |
Queues a build (recipe v1 or v2). |
templates.builds.get, list, cancel, logUrl, builderAvailability |
Builds of the organization. |
templates.builds.waitForBuild(organizationId, buildId, opts?) |
Waits until the build settles (default 30 minutes); throws TemplateBuildTimeoutError (the build continues). |
Test instances and inputs
const test = await cloud.templates.versionTestInstances.create('acme-dev', 4, { inputs: { PROJECT_NAME: 'demo' } });
console.log(test.startup); // start commands and services: pending | running | ready | failed
await test.close();
const ws = await cloud.workspaces.open({ key: 'customer-42/main', template: 'acme-dev', inputs: { PROJECT_NAME: 'acme' } });
console.log(await ws.inputs(), ws.startup);A version test instance (0.7.0+) is a session workspace on a registered version, published or not. A failed start
command or service fails the open (OperationFailedError, errorCode startup_failed, retryable); the workspace
keeps running for inspection, and workspace.startup names the step, its exit code and output tail. The next open runs
the failed step again. Inputs errors are 422: input_unknown, input_invalid, input_required.
Dev mode (drafts)
(0.6.0+) Edit an organization template live in a draft, a layered workspace:
const draft = cloud.templates.draft('acme-dev');
const { workspace: draftWs } = await draft.create({ base: 'python-node-browser@5' }); // one live draft per template
await draftWs.cell().exec.run(['bash', '-lc', 'npm ci']);
await draft.captureState({ label: 'deps installed' });
const trial = await draft.openTestInstance(); // a session on a copy of the state
await trial.close();
await draft.publish({ description: 'npm ci' }); // 409 draft_stale if the template moved on
await draft.discard();draft.get() throws 404 draft_not_found when there is none; draft.find() returns null. states(),
statesAll() and testInstances({ includeEnded? }) list states and test instances. Only owners, admins and API keys
with a tool permission may change drafts; others get 403 template_dev_mode_role.
Secrets
Store credentials once and give them to a workspace's processes as environment variables. Values are write-only: no
API returns them. Every exec and terminal in a workspace receives the secrets bound to it, plus any the call names in
secretRefs.
const projectId = (await cloud.me()).api_key!.project_id;
await cloud.secrets.create(projectId, { name: 'OPENAI_API_KEY', value: process.env.OPENAI_API_KEY! });
const agentBox = await cloud.workspaces.open({ key: 'customer-42/main', template: 'python-node-browser', secrets: ['OPENAI_API_KEY'] });
await agentBox.secrets.get(); // { workspace_id, names, secrets: [{ name, status, ... }] }
await agentBox.secrets.set(['OPENAI_API_KEY', 'DATABASE_URL']); // replace; [] clearscloud.secrets method |
Does |
|---|---|
create(projectId, { name, value, description?, allowedWorkspaceIds?, allowedTools?, idempotencyKey? }) |
Creates a project secret. name matches ^[A-Z_][A-Z0-9_]{0,127}$ (the SHARDFLUX_ prefix is reserved); value is UTF-8 up to 65536 bytes. allowedTools defaults to ['exec', 'pty']. |
list(projectId, { limit?, cursor?, includeDeleted? }?), get(secretId) |
Metadata only. |
update(secretId, { description?, allowedWorkspaceIds?, allowedTools? }) |
Applies from the next session start. |
rotate(secretId, value) |
Stores the next version; older values are erased. Running processes keep what they read. |
versions(secretId) |
Version metadata, newest first. |
delete(secretId) |
Erases the values and removes the name from every workspace binding. |
createOrganization, listOrganization, accessEvents |
Organization-wide secrets and access logs: owners and admins only, so a project API key gets 403. |
- A name that is unknown, or a secret this workspace may not use, is refused with
ShardfluxApiError422 (reasonsecret_not_available,details.names); nothing changes. - Binding status per name:
available,not_allowed(starts are refused with 403 until fixed) ordeleted. - Forks keep the binding; secrets limited to specific workspaces are checked against the fork's own id.
- A bound name that is also passed in
envis refused (422). - Workspace tool-call capture sanitizes bound secret values before persistence.
Account
(0.9.0+) ShardfluxAccount does what a person does in the console, from code: sign up, sign in (with two-factor
authentication), organizations, projects, API keys, members, invitations, billing, the audit log, data exports and
account deletion. It authenticates with a person's CLI session (sfu_<token>, sent as Authorization: Bearer on
/v1), not with an API key. Paying in Stripe Checkout is the step a person does. The
shard CLI is built on it.
import { Shardflux, ShardfluxAccount } from '@shardflux/sdk';
// An agent's signup (0.14.0+): one call, no browser.
const { account: mine, result: created } = await ShardfluxAccount.signup({
email: 'ada@example.com',
acceptTerms: true,
onSessionToken: (s) => save(s.token),
});
// acceptTerms (0.21.0+, also on register): the person agreed to https://shardflux.dev/terms and
// https://shardflux.dev/privacy; the new account records it.
if (created.access !== 'verified') await mine.auth.requestEmailCode(); // then mine.auth.verifyEmailCode('123456')
// Password accounts. Emailed links can be passed whole.
await ShardfluxAccount.register({ email, password, displayName: 'Ada' });
await ShardfluxAccount.verifyEmail('<link from the verification email>');
const { account, result } = await ShardfluxAccount.login({ email, password, onSessionToken: (s) => save(s.token) });
if (result.status === 'mfa_required') await account.auth.completeMfa({ code: '123456' }); // or { recoveryCode }
// Later, with the saved token (sfu_ and 43 characters; a malformed one throws).
const again = new ShardfluxAccount({ sessionToken: saved, onSessionToken: (s) => save(s.token) });
const org = await again.organizations.create({ name: 'Acme' });
const project = await again.projects.create(org.id, { name: 'Default' });
const { secret } = await again.apiKeys.create(project.id, { name: 'agent', toolPermissions: ['exec', 'files'] });
const cloud = new Shardflux({ apiKey: secret }); // the sfk_ key, shown onceOptions: sessionToken, baseUrl, fetch, userAgent, timeoutMs, maxRetries, onSessionToken,
versionCheck and onProgress. isSessionToken(value) and SESSION_TOKEN_PATTERN check a token's shape.
| Before a session (static) | Does |
|---|---|
signup({ email?, displayName?, onSessionToken? }) |
(0.14.0+) { account, result } at once. result.access: verified, provisional (creates organizations, projects and API keys before email verification; workspace compute still requires the signup trial or a paid plan) or verification_required. |
register({ email, password, displayName? }) |
Creates the account and emails a verification link. It answers the same whether or not the address exists. |
verifyEmail(linkOrToken) |
Verifies the email address. |
login({ email, password, onSessionToken? }) |
{ account, result }: result.status is authenticated or mfa_required. |
requestPasswordReset(email), confirmPasswordReset({ token, newPassword }) |
Password reset by email. |
confirmEmailChange(linkOrToken) |
Confirms a new email address. |
| Namespace | Methods |
|---|---|
auth |
session, completeMfa, logout, logoutAll, sessions, revokeSession, stepUp ({ password }), changePassword, changeEmail, resendVerification, requestEmailCode and verifyEmailCode(code) (0.14.0+), totp.enroll, totp.confirm, totp.disable, totp.regenerateRecoveryCodes |
organizations |
list, listAll, create, get, entitlements, deletion, delete(id, { confirmation }), exports.create, exports.get, exports.download, workspaces |
projects |
list, listAll, create, get |
apiKeys |
list, create(projectId, { name, toolPermissions?, expiresAt?, idempotencyKey? }), revoke |
members |
list, update(orgId, userId, { role }), remove |
invitations |
list, create(orgId, { email, role }), revoke, accept(linkOrToken) |
billing |
catalog, subscription (with trial (0.32.5+)), checkout(orgId, { planKey }), checkoutStatus, waitForCheckout, cardCheck(orgId), cardCheckStatus, waitForCardCheck (0.32.5+), portal, invoices, spendPolicy, setSpendPolicy(orgId, { alertThresholdsPercent }) |
user (the signed-in person) |
deletion, scheduleDeletion({ confirmation }), cancelDeletion, exports.create, exports.get, exports.download |
templates |
Every cloud.templates method, plus publishVersion(orgId, slug, version) and archiveVersion(...) |
audit |
list, listAll, export(orgId, { format: 'csv' | 'ndjson', ...filters }) (the text) |
usage, secrets, egress, volumes, workspaces |
The same APIs as on Shardflux, with explicit organization and project ids and the person's permissions. |
Plus me() and request(method, path, init?). List methods return the API's page { data, next_cursor };
listAll iterates every page.
- The token rotates. Completing a sign-in,
auth.stepUp(),auth.changePassword(),auth.totp.confirm()andauth.totp.disable()return a new token and revoke the previous one.account.sessionTokenalways holds the current token, every later call uses it, andonSessionToken({ token, expiresAt })is called (and awaited) with each new one: save it there. A session lasts 30 days from its last use and at most 90 days. - Step-up. Exports, deletions, email and two-factor changes answer 403
step_up_requiredwithout a recent password check:await account.auth.stepUp({ password, code }), then retry. A session still waiting for its second factor gets 403mfa_required; an unverified email gets 403email_unverified. See Errors. - API keys.
apiKeys.create()sends anIdempotency-Key, so a retried request returns the same key and secret instead of creating a second key.toolPermissionsdefaults to[](a key without workspace tools);API_KEY_TOOL_PERMISSIONSlists every tool. The secret is in the result once. - Emailed links.
parseEmailToken(input)returns thetokenof a link's#token=fragment or?token=query (percent-decoded), else the trimmed input, and throws for an empty input or a link without a token. Every method that takes an emailed token accepts the whole link.
Upgrading a plan: a person pays at the Checkout url, and the code waits for the subscription:
import { CheckoutTimeoutError } from '@shardflux/sdk';
const checkout = await account.billing.checkout(org.id, { planKey: 'developer' });
console.log(`Pay here: ${checkout.url}`);
try {
const done = await account.billing.waitForCheckout(org.id, checkout.id, { timeoutMs: 15 * 60_000 });
if (!done.subscription_active) console.log(`checkout ${done.status}`); // expired, canceled or failed
} catch (err) {
if (err instanceof CheckoutTimeoutError) console.log(`still open after ${err.waitedMs} ms: ${err.checkout.url}`);
else throw err;
}waitForCheckout(orgId, checkoutId, { timeoutMs?, intervalMs?, signal? }) polls until the subscription is active or
the checkout expired, was canceled or failed. After timeoutMs (default 15 minutes) it throws CheckoutTimeoutError
(the checkout stays payable); on abort, the signal's reason. An organization that already has a subscription gets 409
conflict with reason: 'subscription_exists' from checkout(): plans change in billing.portal(orgId).
Feedback
cloud.sendFeedback() sends immediately to the Shardflux team. It is an explicit manual API and does not show a consent prompt. Agents: report only when your user asks to send feedback. Show the exact message and context first, then obtain explicit authorization. A failure or request for a feature is not consent. Leave out private data, secrets and code the user did not mean to share; paraphrase, never paste transcripts. Tell the user what was sent.
Preview a report without sending:
const feedback = {
message: 'open rejected template "python-node"; expected a suggestion of the closest slug',
category: 'bug' as const,
context: { requestId: 'req-example', errorCode: 'template_not_found', workspace: 'acme/demo' },
};
console.log(feedback);After the user explicitly approves that message and context, send it in a separate action:
await cloud.sendFeedback(feedback);message: any length, not empty (0.32.5+).category:bug,confusing,missing,idea,praiseorother(the default).context(all optional):agent(who is reporting),workspace,requestId,errorCode,command, andclient, which defaults toshardflux-sdk-ts/<version>.- Returns
{ id, receivedAt, duplicate }. The same message from the same key within 24 hours returns the original withduplicate: trueand sends no second email. - Any API key may send feedback (no tool permission). With a CLI session instead,
account.sendFeedback({ ..., organizationId? })sends it as the signed-in user, optionally about one of their organizations. - Rate limited: 10 per 10 minutes and 50 per day per key or user, 200 per day per organization; a
ShardfluxApiErrorwithcode: 'rate_limited'andretryAfterSeconds. The SDK never retries the call. - Anything shaped like an API key, token or private key is redacted before the message is stored or emailed.
Version check
(0.9.0+) After the first successful API response of a process, a Shardflux or ShardfluxAccount client checks
in the background whether this SDK is current (once per process, 3 s timeout, every error ignored; it never delays or
fails a call): the latest release from npm (0.22.0+; SHARDFLUX_NPM_REGISTRY names another registry) and the oldest
supported one from GET /v1/client-versions. When this SDK is outdated or no longer supported, it emits one warning:
(node:1234) [SHARDFLUX_UPDATE_AVAILABLE] ShardfluxUpdateWarning: @shardflux/sdk <installed-version> is outdated: <latest-version> is available. Update: npm install @shardflux/sdk@latest- It goes through
process.emitWarningwith typeShardfluxUpdateWarningand codeSHARDFLUX_UPDATE_AVAILABLE. Handle it withprocess.on('warning', (w) => ...)(w.name === 'ShardfluxUpdateWarning'). - Turn it off with
versionCheck: falseon either client,SHARDFLUX_NO_UPDATE_CHECK=1(alsotrue,yes,on) orNO_UPDATE_NOTIFIER=1. - A tool built on the SDK checks its own package instead:
versionCheck: { package: '@acme/tool', version: '1.2.3' }. The CLI and the MCP server do this. - On demand:
await checkClientVersion({ baseUrl?, fetch?, package?, version?, ecosystem?, timeoutMs?, signal?, registry? })(registry: the npm registry or PyPI URL, orfalseto ask the API only; 0.22.0+) returns{ status, package, ecosystem, current, latest, minimumSupported, upgradeCommand, releaseNotesUrl, message? }.statusiscurrent,outdated,unsupported(below the API's minimum) orunknown(the request failed, or the package has no published version); it never throws.compareVersions(a, b)returns -1, 0 or 1 for twomajor.minor.patchversions (a pre-release sorts before its release), ornullwhen one does not parse.
Errors
| Class | When | Fields |
|---|---|---|
ShardfluxApiError |
The API or the cell gateway refused the request. | status, code, message, requestId, retryable, details, operationId, retryAfterSeconds, reason (details.reason), source (api or cell), timing |
ExecStartError |
(0.10.0+) exec.run(): the command could not start. A ShardfluxApiError with code: 'conflict' and reason: 'exec_failed_to_start'. |
sessionId, session, details.error (the workspace's reason) |
OperationFailedError |
An awaited operation ended failed or canceled. |
operation, operationId, errorCode, retryable (0.6.2+), workspaceActive (0.12.0+: a suspend when idle canceled workspace_active; the workspace keeps running**)**, timing |
OperationTimeoutError |
A wait gave up; the operation continues. | operationId, workspaceId, lastState, lastReason, deadlineAt (0.6.2+), queuePosition (0.22.0+), waitedMs, durable (0.12.0+: the wait was for a durable copy, which continues**)**, timing |
DurabilityLostError |
(0.12.0+) A durable wait found durability.state: "lost" (Errors). |
operation, operationId, workspaceId, durability, timing |
ShardfluxProtocolError |
A response was not the documented shape (for example a proxy error page). | status |
ToolArgumentError |
executeToolCall got invalid arguments. |
tool, issues |
TemplateFileError |
(0.7.0+) A template file or local path cannot be read, parsed or packed; nothing was sent. | |
TemplateUploadError |
(0.7.0+) The storage refused an upload. | status, code, sha256 |
TemplateBuildTimeoutError |
A build wait ran out; the build continues. | build |
CheckoutTimeoutError |
(0.9.0+) billing.waitForCheckout() ran out of time; the checkout stays payable. |
checkout, waitedMs |
NotSupportedForModeError |
(0.9.0+) A ShardfluxApiError (409 conflict, not_supported_for_mode): the call does not exist for the workspace's mode. |
mode, operation, local (refused by the SDK without a request) |
TreeRevisionMismatchError |
(0.9.0+) A ShardfluxApiError (409 conflict, tree_revision_mismatch): an ifTreeRevision call found the tree at another revision; nothing changed. |
currentTreeRevision |
import { ShardfluxApiError } from '@shardflux/sdk';
try {
await cloud.workspaces.open({ key: 'customer-42/main', template: 'python-node-browser' });
} catch (err) {
if (err instanceof ShardfluxApiError) {
console.error(err.status, err.code, err.reason, err.message, err.requestId, err.retryable);
}
throw err;
}Errors are built by reason, so the two subclasses come from any call (0.9.0+), and their name is the subclass's:
compare with instanceof, not err.name. ShardfluxApiError.treeRevision is a file-first refusal's
X-Tree-Revision. 503 host_capacity (the workspace's host is making room to wake it) is retried for any call
within admissionTimeoutMs (0.22.0+); isAdmissionRefusal(err) recognises one that outlasted it, as do
isHostCapacityRefusal(err) and isWorkingQuotaRefusal(err) for each kind. 503 wake_failed (a parked workspace
could not be woken) is retried after Retry-After for reads, searches and calls with an Idempotency-Key. 409
host_feature_unavailable is neither retried nor woken (see Errors).
code is typed as ErrorCode (the API's and the cell gateway's closed enums) and reason as ErrorReason
(KnownErrorReason plus any string). Treat unknown codes and reasons as generic errors: show message and use
retryable. The full list is in Errors.
A 402 allowance_exhausted (opens, resumes and forks refused while an allowance is used up) has a reason, which
KnownErrorReason includes (0.10.0+). Do not retry it in a loop:
reason |
What to do |
|---|---|
allowance_used |
Upgrade, or have an owner or billing member turn on overage; or wait for details.resets_at. |
overage_paused |
A plan payment is past due: an owner or billing member updates the payment method. |
spend_cap_reached |
Raise the spend cap (up to the plan price) or upgrade; or wait for details.resets_at. |
details.spend_cap has cap_minor, effective_cap_minor, charges_minor and currency. See
402 allowance_exhausted.
With GPU-hours used up, the same three reasons stop only the GPU (0.25.0+): frame requests
answer allowance_exhausted while workspaces keep running (summary().gpu.blocked_reason). A feature the plan does
not include is 402 entitlement_required with reason not_included (0.25.0+, in KnownErrorReason) and
details.plan, for example the GPU (GPU needs the Startup or Scale plan.): upgrade.
Retries and idempotency
- Requests are retried only when a retry cannot duplicate an effect:
GETandHEAD, and requests that carry anIdempotency-Key. The SDK sends a fresh key with every create and lifecycle call (open, suspend, resume, snapshot, fork, delete, close, reset, save as template, secrets, builds, drafts, file writes), so transport retries replay the original response. PassidempotencyKeyto reuse a key across your own retries. - Retried failures: network errors, and 429, 502, 503 or 504 responses whose error is
retryable, at mostmaxRetriestimes. The delay is the fullRetry-Afterwith up to 20 % jitter (0.22.0+; before, at most 5 s**)**, else 200 ms doubling (capped at 2 s for network errors, 5 s for responses). ARetry-Afterthat would outlast the request's timeout throws the error at once, withretryAfterSeconds. - The cell client refreshes the tool token once on
401or409 stale_epoch, waits outworkspace_busyand wakes a suspended workspace; a refused call was never executed, so the retry is safe. - Calls wait for room (0.22.0+). A call refused before it ran because room is being made for it is sent again
whatever its method, exec starts, PTY opens, writes and opens included: 503
host_capacity(the workspace's host is making room to wake it) and 429quota_exceeded(workspaces working at once, from the cell gateway, or from the API for opens, resumes and forks). The retries share one wall-time budget,admissionTimeoutMs(default 120 000 ms; on the client,cell(),open(),exec.run(),executions.run()and per request), and do not count againstmaxRetries. Each wait is the fullRetry-Afterwith up to 20 % jitter (full-jitter exponential backoff without one); a wait that would pass the budget throws the refusal at once. Each retry is aretryprogress event withadmission: true.quotaRetry: falsein a request's options turns them off. Before 0.22.0, only the cell gateway's 429 was retried (0.14.0+), withinmaxRetries. - A failed operation (for example
capacity_unavailable) is never retried by the SDK.
Compatibility
- The SDK follows the API's
/v1contract. New fields, enum values and error codes can appear in any release; ignore unknown fields. - Breaking changes ship only in minor releases (0.8 to 0.9) and are marked Breaking in the changelog. 0.9.0 adds
ShardfluxAccount, the version check, file search and patches, the wake hint and file-first workspaces. Its changes in behaviour: the background version check (one request per process;versionCheck: falseturns it off), a wake of a suspended workspace is one held resume, reads of a suspended workspace are served from its disk without waking it, and error classes are built by reason (theirnameis the subclass's). Breaking:workspaceTools()readsworkspace.modewhile building the definitions unlessmodeis given. 0.8.0 changes types only: the tool exports' return types andJsonSchema(now a type alias); nothing changes at run time. 0.7.0's breaking changes are type-only:TemplateBuild.recipeis{ dockerfile }or the stored recipe v2 (narrow with'dockerfile' in build.recipe), andLifecyclePhasegainscapture_flush. - 0.13.0 adds the immutable paths fields and error reasons.
Breaking, type-only: the
UpdatePolicytype and theupdate_policyfields of templates, workspaces anddefaultsare gone, as they are from the API. - 0.22.0 sends calls refused before they ran again for any method, within
admissionTimeoutMs, and waits the fullRetry-After. Breaking: a wait whosetimeoutMspasses while its start iscapacity_pendingkeeps waiting until the start's deadline;untilCapacityDeadline: falsekeeps the previous behaviour. - The
shardfluxnpm package re-exports everything from@shardflux/sdk(see CLI). - Python: the
shardfluxpackage covers workspaces, exec, files, templates, secrets, tool-call capture, agent tools and, from 0.5.0,ShardfluxAccount.
Integration controls (0.12.0+)
Open accepts labels: Record<string,string> and idlePolicy: 'adaptive' | 'never' | \fixed:${number}`. List accepts labelsfor exact matches.ws.labels, ws.setLabels(labels), ws.idle(), ws.keepalive(seconds)andws.setIdlePolicy(policyOrNull)` are typed methods; null clears the override. Idle inspection does not wake or record activity.
Node 26 uses a private HTTP/1.1 pool. Caller-supplied fetch is unchanged. SHARDFLUX_HTTP_KEEPALIVE=0 forces close for diagnosis, =1 uses native pooling.
Start a pipe with ws.cell().exec.start({session_id: 'worker-1', argv: ['cat'], stdin_open: true}). Write via cell.exec.input(id, data, {offset: 0}), then use the acknowledged byte offset for later writes and {close: true} for EOF. Frames are at most 64 KiB. Continue from partial acknowledgements; retry only an identical last frame. Do not combine stdin_open with one-shot stdin. Host and guest must support exec_stdin / exec_stdin.v1; older runtimes refuse explicitly. These sessions participate in the existing bounded idle-command policy.
ShardfluxProtocolError.source is api/cell (unknown only for manually constructed errors without a source). isWorkspaceGone(err) only recognizes an explicit API workspace_deleted refusal. A 404 alone never proves deletion.
For release-age policies, retain the policy and pin a sufficiently old exact version. The first SDK, 0.5.0, reaches seven days on October 3, 2026 at 21:13:52 UTC; each newer release needs seven days from its own publication. npm view @shardflux/sdk time --json provides the dates. Older releases do not contain newer fixes.
Prepare streamed tool input (0.14.0+)
Pass { prewake: true } to workspaceTools() to expose an onInputStart() callback on VM tools. Call the matching callback when your model starts streaming that tool input, then execute normally after its arguments are complete. It returns immediately. Default tools, offline file reads and file-first workspaces install no hook.
Turn and integration helpers (0.16.0+)
ref.turn(fn, { afterSeconds: 0 }) and workspace.turn(fn) hint without waiting, return the body's result, and
request idle suspension when it settles, including on throw. computer.stream({ fresh: true }) remints a viewer
link; otherwise a link of the same kind with more than 60 seconds left is reused. Computer tool adapters accept
beforeAction per-action policies; a refusal becomes that action's error and skips the rest.
Exec and execution results expose exitCodePosix: timeout 124, canceled 130, a nonnegative exit code, 128 + signal,
otherwise 1. FileNotFoundError extends ShardfluxApiError for missing file paths. The default transport honors
HTTP_PROXY, HTTPS_PROXY, NO_PROXY and NODE_USE_ENV_PROXY. SHARDFLUX_BASE_URL aliases SHARDFLUX_API_URL;
SHARDFLUX_API_URL wins when both are set.
Upgrade in place (0.16.0+)
await ws.upgrade({ at: 'now', wait: true }) keeps files and installed package files through a cold start.
at: 'next_resume' schedules it without stopping the VM. ws.upgradeAvailable and ws.upgradePending expose
the target. The returned operation says whether it is scheduled or applied. Also cloud.workspaces.upgrade(id, opts).
See upgrade while keeping your files.
Workspace controls (0.18.0+)
All client additions here require TypeScript SDK 0.18.0+.
const ws = await cloud.workspaces.open({ key: 'acme-web/review', like: 'acme-web/main' });like copies the exact template version, sizes, secret bindings, inputs and volumes onto a fresh
disk for a new key. Supplied values override inheritance. An existing key keeps its disk and template.
volumes maps absolute mount paths to names or {name, mode: 'ro'|'rw'}; an empty set detaches all, and omission
preserves the set. Waited opens continue successive mount changes under one timeout.
cloud.volumes.open({name, quotaGib?}) creates or reuses named storage; new storage defaults to 1 GiB.
Shared-volume limits apply across the account.
Terminals and tunnels
PtyConnection carries persistent terminal input, resize and output. PtyConnection.attach(cell, id, offset, signal, { readOnly: true, passive: true }) watches a terminal without typing into it (input() and resize() throw) and
without waking its workspace while it is idle; cell.pty.attachUrl(id, offset, { readOnly, passive }) gives the URL.
workspace.tunnels.forward({remotePort, stream, signal?, transport?}) forwards a caller-owned stream
to guest loopback (127.0.0.1, then ::1 when nothing listens there; 0.26.0+); close its handle when done. Reverse and forward tunnels pass half-close and resets through: a
reset on either side reaches the other as ECONNRESET (0.20.0+).
Tunnel session status (0.28.0+): exec.get(id) and pty.get(id) expose optional tunnel_replay: TunnelReplayStatus when the guest reports it. See session status for the states.
Browser sessions
cloud.browserProfiles.list({projectId?}), .put(name, {scope, sites, projectId?}) and
.remove(name, {site?, projectId?}) manage write-only selected-site browser sessions. Scope is project or
{workspace_id}; sites carry Playwright cookies and origin/local-storage state. Reads return metadata.
Stored encrypted with AWS KMS; only this project's workspaces can load it, and every load is logged.
Site logins (0.32.5+)
ShardfluxAccount.siteLogins manages your encrypted sign-in store with your user session.
| Method | Result |
|---|---|
list({ site?, projectId?, organizationId? }) |
Login metadata, including username, scope, TOTP and passkey marks, provenance and last use |
add({ site, username?, password?, totp?, passkeys?, scope?, projectId?, conversationId?, organizationId? }) |
Saved login metadata |
update(id, changes) |
Updated metadata; accepts scope, related origins and credential values |
remove(id) |
Deletes the login |
reveal(id) |
Password, TOTP seed and current code, plus passkey count; audited and user-session only |
linkVault(id, { vault, item }) |
Metadata with the local vault copy recorded |
Scope defaults to agents (all your agents); project and conversation use the corresponding id.
totp accepts seed, digits, period and algorithm. Values stay outside model context.
credentialRequests.create and fill take an optional account username and support the passkey field.
The account's credentialRequests.list() lists requests across projects; projectId narrows it.
Account creation requests (0.33.2+): needsYou.answer(id, { decision: 'create_account' }) asks the requesting agent to create the account and store the credentials securely. credentialRequests.create({ ..., conversationId }) binds a sign-in request to its chat. A repeated request reads creation_requested; no existing secret was provided.