@shardflux/sdk
Serverless VMs without lifecycle code.
Give each customer of your product a persistent computer for their agent: open a workspace by a key you choose
(customer-42/main) and the same key returns the same Linux microVM, with its files, tools and running processes. It
parks seconds after its last call, wakes on the next call or HTTPS request, and you pay for the compute it uses while
Shardflux manages the infrastructure.
@shardflux/sdk is the TypeScript SDK for Shardflux, built for your product's own agent
loop: your app keeps its own identity, model calls and agent loop; Shardflux is the computer underneath.
- Workspaces by key.
workspace('customer-42/main', { template }) makes no request: the first call creates the
workspace, and every later call, from any process, reaches the same one.
- Park and wake on use. An idle workspace parks and the next tool call or HTTPS request wakes it. Suspend and
resume keep memory and running processes;
fork() copies files, memory and processes to a new key.
- Sized by use. Elastic memory grows with the commands that need it,
resize() changes memory, CPU and disk
live, and a burst runs one heavy command on a larger VM.
- Templates. Build your own from
template.yaml, develop it live in a draft, and ship read-only paths that
follow its newest version.
- Ports and previews. Serve any port at its own private HTTPS URL, embed a live viewer in your app, and tunnel
back to services next to your code.
- Secrets and shared volumes. Write-only secrets bound to workspaces, and persistent storage shared between them.
- Computer use. A desktop the agent drives with screenshots, mouse and keyboard.
- File-first workspaces. A versioned file tree, with a fresh VM for each command.
- Agent tools. Exec, files, processes, terminal, git, browser and computer tools for Anthropic and OpenAI tool
calls, plus tool-call capture for your harness's own tools.
- GPUs on the Startup and Scale plans, metered as GPU-hours in usage.
The same platform has an HTTP API, a Python SDK (shardflux on PyPI), the shard CLI (@shardflux/cli) and an MCP
server (@shardflux/mcp). Plans, sizes, regions and prices: docs.shardflux.dev/limits.
Compatibility. The API is versioned (/v1). Breaking changes ship only in minor releases and are marked
Breaking in the changelog (see Compatibility).
Versions. Features are marked with the version that adds them, for example (0.17.0+);
CHANGELOG.md lists what each version added. Check yours with npm ls @shardflux/sdk or the
exported SDK_VERSION.
Install
npm install @shardflux/sdk
- ESM only, Node.js 24 or later. Reading a YAML template file uses the optional peer
dependency
yaml (npm install yaml); JSON template files need nothing.
- Typed from the published OpenAPI documents.
- Retries, idempotency keys, operation polling and tool-token refresh are handled for you.
Runtime security
Runs on Node.js 24.22.1 or 26.12.1 and later releases in each line. Updating the SDK does not update Node.js itself.
Quick start
Create a project API key in the Shardflux console (sfk_<key id>_<secret>) and keep it on the
server. API keys are server credentials: never put one in a browser bundle.
No account yet: sign up in the Shardflux console or
from code.
import { workspace } from '@shardflux/sdk';
const ws = workspace('customer-42/main', { template: 'default' });
const run = await ws.exec('python3 -c "print(40 + 2)"');
if (run.exitCode !== 0) throw new Error(`python3 exited ${run.exitCode}: ${run.stderr}`);
console.log(run.stdout.trim());
await ws.files.write('/home/user/notes.txt', 'hello from the SDK\n');
await (await ws.open()).suspend({ wait: true });
const again = workspace('customer-42/main', { template: 'default' });
console.log(await again.files.readText('/home/user/notes.txt'));
The key names the workspace (0.15.0+): the first call creates it, later calls (from any process) reuse it, and it
suspends when idle and wakes on the next call with its files, packages and processes. There is no create call and no
lifecycle code to write. See Workspaces by key.
Open, suspend and resume
The same workspace with every step explicit:
import { Shardflux, formatTiming } from '@shardflux/sdk';
const cloud = new Shardflux({ apiKey: process.env.SHARDFLUX_API_KEY! });
const open = () => cloud.workspaces.open({ key: 'customer-42/main', template: 'python-node-browser' });
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());
await workspace.cell().files.write('/home/user/notes.txt', 'hello from the SDK\n');
await workspace.suspend({ wait: true });
const again = await open();
console.log(await again.cell().files.readText('/home/user/notes.txt'));
console.log(formatTiming(again.lastTiming!));
open() waits until the workspace is running. Opening the same key again never resets it: files,
installed packages and running processes are still there. The same program is in
examples/quickstart.ts.
With 0.5.0, wait for the suspend by its operation instead:
await cloud.workspaces.waitForOperation((await workspace.suspend()).id).
Workspaces by key (0.15.0+)
workspace(key, params) (or cloud.workspace(key, params) on your own client) names a workspace by the identifier your
application already has: a user, a thread, a repository, a customer. It makes no 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 brings
back its tool token. Later calls go straight to the workspace.
import { workspace } from '@shardflux/sdk';
const ws = workspace(`acme/${threadId}`, { template: 'default' });
await ws.exec('pip install requests && python3 app.py');
await ws.exec(['python3', '-c', 'print(42)']);
await ws.files.write('/home/user/notes.txt', 'hello\n');
console.log(await ws.files.readText('/home/user/notes.txt'));
ws.created;
const full = await ws.open();
params are open()'s without the key: template is required ('default' is the platform default template,
python-node-browser), plus secrets, inputs, caps, labels, idlePolicy, lifetime, computerUse,
agentLabel, tools.
- Calls made while the first open runs share it. A failed open is not kept: the next call opens again.
create: false never creates: the first call finds the key's workspace and fails with 404 not_found when it has
none.
- A workspace deleted under the ref (
isWorkspaceGone(err), 409 workspace_deleted) fails the call that meets it;
the next call opens the key again, which creates a new workspace.
ws.hint() starts the open early, for example when your model starts a tool call.
In an agent. The tools exist before the workspace does; the model's first tool call creates it:
import { toAnthropicTools, executeToolCall, workspace } from '@shardflux/sdk';
const tools = await workspace(`acme/${threadId}`, { template: 'default' }).tools();
const anthropicTools = toAnthropicTools(tools);
const output = await executeToolCall(tools, block);
One turn (0.16.0+). ref.turn(async (ref) => { ... }) (also workspace.turn) sends a wake hint without waiting,
returns the body's result, and requests suspendWhenIdle({ afterSeconds: 0 }) when it settles, including on error.
Pass { afterSeconds: 60 } to keep the workspace warm for a billed minute. Use persistent workspaces for resumable
turns.
Configuration
const cloud = new Shardflux({
apiKey: process.env.SHARDFLUX_API_KEY!,
baseUrl: process.env.SHARDFLUX_API_URL,
timeoutMs: 30_000,
maxRetries: 2,
admissionTimeoutMs: 120_000,
versionCheck: true,
});
Admission queues (0.29.0+). Workspace opens follow the API queue automatically, preserving their request body and Idempotency-Key. Retry progress reports admission_queued, the full Retry-After delay and time waited. Reads and other safely replayable calls share admissionTimeoutMs (default 2 minutes); cancellation and explicit retry controls still apply.
Calls wait for room (0.22.0+). A call refused before it ran because room is being made for it, on the
workspace's host (503 host_capacity) or in the plan's workspaces working at once (429 quota_exceeded), is sent
again whatever its method, exec starts and opens included, for up to admissionTimeoutMs of wall time. Each wait is the
refusal's Retry-After with up to 20 % jitter, and each retry is a retry progress event with retry.admission,
reason, queuePosition, retryAfterSeconds and waitedMs:
const cloud = new Shardflux({
onProgress: (e) => {
if (e.type === 'retry' && e.retry.admission) console.error(`waiting for room (${e.retry.reason}, position ${e.retry.queuePosition ?? '?'})`);
},
});
new Shardflux() with no apiKey reads SHARDFLUX_API_KEY, and with no baseUrl reads SHARDFLUX_API_URL
(0.15.0+); options you pass win. A missing key throws when the client is created. The module-level workspace()
uses such a client, created on its first call.
The default transport honors HTTP_PROXY, HTTPS_PROXY, NO_PROXY and NODE_USE_ENV_PROXY (0.16.0+).
SHARDFLUX_BASE_URL aliases SHARDFLUX_API_URL; SHARDFLUX_API_URL wins.
From 0.12.0, Node 26 reuses TLS connections through a private HTTP/1.1 pool. From 0.13.0, an idle connection stays
reusable for 5 minutes, so the call after an agent's pause between tool calls skips the TCP and TLS handshake, and
pooled sockets send TCP keepalive probes after 60 s idle. Idle connections never keep a process alive. Other runtimes
keep native fetch. A supplied fetch stays unchanged. SHARDFLUX_HTTP_KEEPALIVE=0 forces connection closure for
diagnosis; =1 opts into native pooling. No global dispatcher is installed.
A request the SDK retries (safe methods, requests with an Idempotency-Key, read-only POSTs) whose connection closes
before the response arrives is sent again at once on a new connection (0.13.0+), without backoff and outside
maxRetries; timing records list it with delayMs: 0.
Commands and files
workspace.cell() returns a client for the workspace's tools. It obtains short-lived tool tokens
from the API, reuses them until shortly before they expire, and fetches a new one when the workspace
moves or resumes.
const cell = workspace.cell();
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),
});
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');
exec.run() starts the command and streams its output in one request (0.13.0+), resumes from
byte offsets if the output stream drops, and never starts the command twice. Aborting its signal
also cancels the command in the workspace. File writes are atomic
and durable (acknowledged after fsync).
cwd is an absolute path (commands start in /home/user without one). The API refuses a relative
cwd with 422 validation_failed (err.reason === 'invalid_cwd'); the message names the absolute
path it likely means. A command that could not start (a cwd that is not a directory, a program
that is not on PATH) rejects with ExecStartError (0.10.0+), whose message is the workspace's
reason; before 0.10.0 exec.run() resolved with exitCode: null and no output.
import { ExecStartError } from '@shardflux/sdk';
try {
await cell.exec.run(['ls'], { cwd: '/home/user/app' });
} catch (err) {
if (!(err instanceof ExecStartError)) throw err;
console.error(err.message);
}
Exec results expose exitCodePosix (0.16.0+): timeout 124, canceled 130, nonnegative exit code, 128 + signal,
otherwise 1. FileNotFoundError is a ShardfluxApiError for cell path_not_found replies.
Exec survives a dropped connection (0.27.0+). A lost start response is retried with the same session ID and
reattaches to the command that already started, and a cut-off JSON answer is retried within the same safe retry
boundary.
Pending execution cancellation (0.27.0+)
A successful cancellation can return the actual running state with canceled: true while a killed process is still
exiting. The session's error explains the pending exit and, after 30 seconds, reports it as unverified rather than
claiming an exit. Read or follow the session until its real final status; repeating the cancellation is idempotent. A
foreground command that already exited keeps its result while its child processes finish. The agent tools report the
reason as status_reason and keep the exit code null while the command is active.
Search, patch and revisions (0.9.0+)
const hits = await cell.files.search('/home/user/project', 'TODO', { include: ['**/*.py'], contextLines: 1 });
for (const m of hits.matches) console.log(`${m.path}:${m.line}:${m.column}: ${m.text}`);
const { revision } = await cell.files.stat('/home/user/project/app.py', { revision: true });
const patched = await cell.files.patch({
path: '/home/user/project/app.py',
edits: [{ oldText: 'DEBUG = True', newText: 'DEBUG = False' }],
expectedRevision: revision,
});
patched.revision;
const { data, revision: current, servedFrom } = await cell.files.readWithInfo('/home/user/project/app.py');
search() searches a directory (or one file) and returns matching lines in path order (path, 1-based line and
byte column, text), stopping at maxMatches (default 200), a 10 s budget or 4 MiB of results (truncated,
stop_reason). include/exclude globs are gitignore-style: *.py matches a name at any depth, src/**/*.ts
a path relative to the searched directory, build/ directories only. Binary files, symbolic links, files above
maxFileBytes and .git/node_modules (unless exclude is given) are skipped.
patch() requests are limited to 7 MiB (413 payload_too_large; write larger files with write()) and files to
64 MiB.
patch() applies all edits or none, atomically and durably, and always sends an Idempotency-Key. content
replaces the whole file instead; expectedRevision: 'absent' requires that the file does not exist yet. A changed
file is 409 conflict with reason revision_mismatch and details.current_revision; an edit that does not match
exactly once is 422 edit_not_found or edit_ambiguous with details.index.
- A suspended workspace is read, listed and searched from its saved disk without waking it; such
results say
servedFrom: 'disk' (served_from on search results). Everything else wakes it as usual. This also
works for a handle without a tool token from before the suspend: the API issues tokens for suspended workspaces.
- If search or patches are not available for a workspace, the call fails with 409
conflict, reason
host_feature_unavailable and details.feature (file_search, file_patch), not retryable: run grep with
exec, or read then write the file, instead. Revisions are omitted there.
Wake hint (0.9.0+)
An idle running workspace is parked and restored by the next tool call. workspace.hint() says a tool call is
coming so the restore starts earlier: call it when your model starts
emitting a tool call, before its arguments are complete. It is cheap and returns at once; a suspended workspace is
resumed in the background (result.wake). The agent tools below send it when each call starts.
void workspace.hint().catch(() => {});
The same client has exec.start/get/output/signal/cancel, pty, processes, git and
browser (screenshot and page content).
Sealed-file reads (0.17.0+)
cell.files.readSealedMany(paths, { generation? }) reads up to eight ordered complete files (1 MiB combined) from one
explicitly suspended sealed generation. Binary bytes, metadata and SHA-256 revisions are preserved; missing paths are
explicit errors. The read_sealed_files tool returns base64 bytes. The read never wakes the workspace.
Use sealed-file reads for complete files from one suspended generation. If the generation changes, request a fresh read. See lifecycle for the read contract.
Blobless Git clones (0.17.0+)
Pass filter: "blob:none" to workspace.cell().git.clone({ url, path, filter }) or the git_clone agent tool to defer historical file blobs. Git fetches the current checkout files immediately and later historical blobs from origin when needed. Use this for full-history clones on an online workspace. Omit filter for the existing complete clone. Shallow clones keep their existing default behavior.
The equivalent exec command is git clone --filter=blob:none -- https://github.com/python/cpython.git /home/user/cpython. This option enables no shared cache, including for private repositories.
Treeless Git clones (0.17.0+)
Set filter: "tree:0" (Python: filter="tree:0") to fetch the requested checkout and commit graph now, with historical trees and blobs fetched from origin as needed. CLI callers use git clone --filter=tree:0 through shard ws exec. See Git for clone options.
Parallel Git checkout (0.17.0+)
Set parallelism (1–16) to choose Git indexing threads and checkout workers for this clone. The git_clone tool forwards it; CLI callers use git -c pack.threads=4 -c checkout.workers=4 clone ... through shard ws exec.
Explicit browser readiness (0.17.0+)
The SDK browser methods and both browser agent tools accept an explicit readiness declaration. Set readiness to describe the visible state needed for this observation. Omit it to wait for the full page load.
A declaration requires 1–32 DOM conditions (selector, exact equals, and
optional property: "text" | "value"). Optional images (up to 32 selectors),
fonts (up to 16 CSS font descriptions and sample text), and focus declare
the state needed by this observation. The helper binds to the new navigation,
checks declared values, decodes images and loads fonts, captures the Chromium
compositor surface, then rejects changed loader or page state. A timeout or
unmet condition fails the request. See browser tools for readiness and timeout behavior.
Lifecycle
await workspace.suspend({ wait: true });
await workspace.resume({ wait: true });
const { workspace: copy } = await workspace.fork({ key: 'customer-42/experiment' }, { wait: true });
await copy.delete();
Requested or finished
suspend, resume, snapshot, fork, delete, reset and close start a lifecycle operation. What the promise
means depends on wait:
await workspace.suspend() | the suspend is requested (usually queued; the workspace is still running) | the operation (Operation) |
await workspace.suspend({ wait: true }) (0.6.0+) | the suspend has finished (workspace.state is then suspended) | the succeeded operation (FinishedOperation) |
await workspace.suspend({ durable: true }) (0.12.0+) | the suspend has finished and its copy is in durable storage (result.durable is true) | the succeeded operation (FinishedOperation) |
wait also takes WaitOptions (timeoutMs, default 5 minutes; signal; onProgress). A failed operation throws
OperationFailedError. Running out of time throws OperationTimeoutError, and the operation continues server side:
wait again with cloud.workspaces.waitForOperation(err.operationId). Without wait, the returned operation is the
handle for the work in progress: pass its id to waitForOperation() when you need it finished.
Lifecycle and durability waits check their timeout again after a polling pause, before the next read. The initial
status read and a completed result from a read already in flight are still observed; queued starts follow their
capacity deadline as described below.
A waited fork is one request (0.13.0+). fork(target, { wait: true }) asks the API to hold the fork until the
copy runs, and the answer carries the running copy and a tool token for it: the copy's handle is ready with no
operation poll, view refresh or token request. agentLabel and tools pick the token it brings back. Its timing is one
request phase with reason held; wait: { serverWait: false } polls instead. An API without the held fork answers
at once, and the SDK then waits for the operation and refreshes the copy, as before.
Durable storage (0.12.0+). A suspend returns as soon as the workspace is sealed on its host, typically in a few
hundred ms, and its RAM and CPU are released at that moment. result.durable turns true when the copy lands in
durable storage, typically within a few seconds; until then it is false and result.durability shows the copy's
progress. Most code needs nothing more: a suspended workspace resumes, is read and is forked the same way either way.
When your code must know the copy is durable (before deleting a local artifact, or at the end of a job), ask for it:
import { durabilityOf } from '@shardflux/sdk';
const op = await workspace.suspend({ durable: true });
durabilityOf(op);
durable: true implies wait and shares its timeoutMs and signal (e.g. { durable: true, wait: { timeoutMs: 60_000 } }).
If the time runs out first, OperationTimeoutError has durable: true and the copy continues server side.
cloud.workspaces.waitForDurable(operationOrId) does the same for an operation you already hold, such as a
finished fork of a running workspace, whose result carries durable and durability the same way.
isDurable(op), durabilityOf(op) and lastTiming.server.durable / .durability read the fields; formatTiming()
prints sealed on host, durable 435 ms later. Results from before 0.12.0 servers count as durable.
- The errors reference on docs.shardflux.dev lists what
durable: true can reject with.
Start deadlines. A queued open, resume, restore or fork (capacity_pending) has a deadline 15 minutes after it
was created. Its error.details.deadline_at carries the deadline; the phase progress event carries it as
deadlineAt (0.6.2+), and so does OperationTimeoutError when your wait ends first. A start still pending at the
deadline fails with capacity_unavailable: nothing was started, the concurrency slot is released, and a suspended
workspace stays suspended with its state. OperationFailedError.retryable (0.6.2+) is true for it, so you can
tell a retryable failure from a definitive one.
Queued starts keep their place (0.22.0+). A wait whose timeoutMs passes while its start is capacity_pending
keeps waiting until the start's deadline (plus 5 s), so open(), resume({ wait }), fork(..., { wait }), wake()
and waitForOperation() see a queued start through. Pass untilCapacityDeadline: false to stop at timeoutMs;
signal ends the wait either way. While it waits, the capacity_pending phase event carries queuePosition,
scaleOut (requested while a new host starts), expectedReadyBy and deadlineAt, and is emitted again when they
change; capacityOf(operation) reads them from an operation, and OperationTimeoutError.queuePosition has the last
position.
try {
await workspace.resume({ wait: true });
} catch (err) {
if (err instanceof OperationFailedError && err.retryable) {
} else throw err;
}
Cancel a suspend (0.26.0+)
cloud.workspaces.suspendProgress(operationId) reads a suspend's actual phase, deadline and cancelable flag without
waking the VM. requestCancelSuspend(operationId) returns once the cancellation is accepted; cancelSuspend(operationId, { timeoutMs: 300_000 }) waits for the same operation to confirm suspend_canceled and the workspace to run on as
before. Other failure or cancellation reasons throw. Cancellation is accepted while cancelable is true, as the suspend
prepares its snapshot; later phases, such as capture, refuse it. It never resets or replaces the workspace; after a
completed suspension, resume the workspace normally. When progress is not available for an operation, the read says so
(supported: false).
Suspend when idle (0.10.0+)
A running workspace is billed while it is awake, and its idle policy waits a while before suspending it. When your
agent's turn ends, ask for a suspend once the workspace has been idle for a short time instead:
const { suspendRequest } = await workspace.suspendWhenIdle({ afterSeconds: 60 });
workspace.suspendRequest;
await workspace.cancelSuspendWhenIdle();
- The idle time counts from the later of the workspace's last work and the request;
not_before is the earliest
suspend.
- A command still running, an attached exec or terminal stream, or a keepalive postpones the suspend until
afterSeconds after it ends.
- The next tool call on the workspace (the next turn) or a resume cancels the request. Repeating replaces it.
- It applies under every idle policy,
never included, and never delays a suspend the policy would do sooner.
- When a suspend is already in progress, the result's
operation is that suspend and nothing is recorded.
- Errors:
ShardfluxApiError 409 with reason not_running, operation_in_progress, session_lifetime or
workspace_deleted, and 422 validation_failed for afterSeconds outside 0..3600. A file-first workspace is never
suspended: NotSupportedForModeError (409 not_supported_for_mode).
- By id:
cloud.workspaces.suspendWhenIdle(id, { afterSeconds }) and cloud.workspaces.cancelSuspendWhenIdle(id).
Suspended workspaces wake on use. A tool call on a suspended workspace resumes it (or joins the resume or open
already running), then runs. A call made during a suspend or resume waits for the transition to finish. The call
never runs twice: the cell executes nothing it refused.
- The wait is bounded per call by
transitionTimeoutMs (default 120 000 ms), shared by the transition waits and at
most 3 wakes. A resume or open still pending at the end throws OperationTimeoutError, naming the operation, its
state and reason. A failed resume or open throws OperationFailedError at once.
- The wake is one request (0.9.0+): the API holds the resume until the workspace runs and answers with the view and a
tool token for the calling client, so the refused call is retried at once (refused call, resume, the call). Its
timing is one
request phase with reason held. An API without the held resume answers at once; the SDK then waits
for the operation, reads the view and fetches a token, as before.
- Following an exec's output never wakes a workspace, so an explicit
suspend() is respected.
workspace.wake({ timeoutMs }) does the same on demand (agentLabel / tools pick the token it brings back).
workspace.resume({ wait: true }) is the same single held request: the handle keeps the view and the token.
workspace.cell({ wake: null }) returns workspace_not_running instead.
const cell = workspace.cell({ transitionTimeoutMs: 30_000 });
await cell.exec.run(['make', 'test']);
Detecting a cold resume (0.11.0+). A resume restores memory and running processes from the checkpoint. After a
platform runtime update, or after the machine the workspace ran on failed (0.13.1+), a resume can boot from the saved
disk instead of restoring memory (a cold boot), also when a tool call wakes the workspace; check memoryRestored. The
workspace runs with its files kept, and its processes start fresh, as after a reboot: start dev servers, databases and
background jobs again. The resume's timing says which it was:
const t = workspace.lastTiming;
if (t?.server?.memoryRestored === false) {
}
ServerTiming.memoryRestored: true when memory and processes came back, false when the workspace booted
(resumePath cold_boot, or reset_blank_layer after a reset), null when the API did not say (older APIs).
ServerTiming.coldBootReason (type ColdBootReason): why it booted, guest_filesystem (0.29.0+), runtime_changed or host_lost
(0.13.1+), else null. formatTiming() prints resume from cold_boot: processes restarted (runtime_changed).
- The finished resume operation carries the same in
result (memory_restored, cold_boot_reason, and
cold_boot with details such as files_as_of; it is informational and not part of the stable contract).
See the workspace lifecycle for retained files and process restart
semantics.
Workspaces recover by themselves (0.13.1+). When the machine a workspace runs on fails, the workspace is suspended,
and its next use (a tool call, resume(), open()) restores it; there is nothing extra to call.
hostLostOf(op) and lastTiming.server.hostLost (HostLost) say how:
restoredFrom: 'disk': it booted from its own disk, files kept (coldBootReason host_lost, processes restarted).
restoredFrom: 'checkpoint': it resumed its newest checkpoint (restoredCheckpointId); its state is as of
stateAsOf. formatTiming() adds restored checkpoint <id> (host_lost; state as of <time>).
- A
suspend() that finds the machine failed succeeds (result.durable true, hostLost.detectedAt).
suspend, resume, fork, snapshot and delete return the lifecycle operation.
cloud.workspaces.waitForOperation(id) waits for it (default timeout 5 minutes): each poll asks the API to hold
the response until the operation changes (Prefer: wait, at most 20 s per request), so completion arrives within
one round trip of the commit. Against an API without bounded waits (or with serverWait: false) it polls with
backoff (250 ms doubling to 5 s, ±20 % jitter). If the timeout passes, it throws OperationTimeoutError and the
operation keeps running server side (a queued start until its deadlineAt); wait for it again with the
same call. templates.builds.waitForBuild() waits the same way. A waited open() issues the first tool token
together with the final workspace read, so the first tool call starts at once.
List and look up workspaces:
const page = await cloud.workspaces.list({ keyPrefix: 'customer-42/' });
for await (const ws of cloud.workspaces.listAll()) console.log(ws.key, ws.state);
const same = await cloud.workspaces.get(workspace.id);
const byKey = await cloud.workspaces.findByKey('customer-42/main');
list() shows persistent standard workspaces by default. Pass lifetime
(persistent | session | any) and purpose (standard | template_draft | template_test | any) to see
sessions, template drafts and test instances. findByKey() searches every lifetime and purpose. It returns the live
workspace, and a tombstone only when no live workspace has the key. Use it rather than listAll({ keyPrefix }) for
lookups by key.
Elastic memory (0.13.0+)
A workspace can hold only the memory its commands use and grow up to memory_mib when they need more; billing counts
the memory it holds. An elastic workspace idles at its held floor (memory_mib_held, default 1024) and shrinks back
after 30 s without work. Package installs, test runners, compilers, type checkers and bundlers get their memory before
they start, so they size their heaps and workers from what they will actually get; everything else starts at once and
the workspace grows while it runs.
const ws = await cloud.workspaces.open({
key: 'customer-42/repo-7',
template: 'node',
caps: { memory_mib: 8192, allocation_mode: 'elastic' },
});
ws.memory;
const r = await ws.cell().exec.run(['npm', 'test']);
r.memoryGrow;
await ws.cell().exec.run(['python3', 'train.py'], { resourceHint: 'heavy' });
resourceHint (0.14.0+) is 'auto' (the default: decided from the command), 'heavy' or 'light'; the exec agent
tool takes resource_hint. allocation_mode: 'fixed' gives a static allocation of memory_mib. Send
allocation_mode with every caps you pass to keep the mode you chose; an open without caps keeps the stored
layout, and a fork without caps inherits its source's. The exec agent tool adds memory_grow to its result when the
command's start grew the workspace.
Resize a workspace (0.14.0+)
Change the memory, CPU and disk of any workspace, running or suspended, fixed or elastic, without a restart:
const r = await ws.resize({ memoryMib: 6144, diskGib: 20 });
r.memory;
r.disk;
await ws.resize({ allocationMode: 'elastic', memoryMibHeld: 1024 });
Memory changes live, and disks grow online. A suspended workspace gets its new size when it resumes, before its first
call. CPU changes live within the vCPUs the workspace booted with; a larger CPU size applies at the next start. Each
resource says when it applies (applies_at: now, resume or next_start), and the new caps are stored, so every
later start uses them.
Resize controls (0.16.0+). workspace.resize({ memoryMib: 6144, diskGib: 2, atLeast: true, wait: false }) grows
only resources that need more capacity and returns the held server answer with its operation. workspace.caps exposes
typed CPU and memory user caps and the effective disk cap. A non-null disk cap is bounded by the admitted plan and
template ceiling and the acknowledged disk size; the original resize request is disk.requested_gib. A pending operation carries per-resource results once the resize has decided
them. Fixed memory without memory_mib uses the template default, bounded by the plan and template.
At its maximum (0.32.5+). A workspace already uses its full CPU and memory from boot, backed as its processes use them,
and its disk grows on its own as it fills, up to the plan's and the template's maximum: resize to lift caps you set
lower, not to speed up a build. Each resource in the result carries at_maximum, true when its target is the most the
workspace can have; with previous_* equal to target_*, nothing changed.
Burst execution (0.13.0+)
Run one heavy command, such as a cold build or a full test suite, on a larger VM without resizing the workspace. With
burst: 'always' the command runs on a short-lived burst VM over a copy of the workspace, output streams as usual,
and its file changes are applied back byte for byte when it exits.
const r = await ws.cell().exec.run(['go', 'build', './...'], { burst: 'always', burstVcpus: 16 });
r.exitCode;
r.burst;
A burst carries back the command's file changes; processes it started and tmpfs content stay in the burst VM, and the
workspace's own processes resume where they were. A burst is not signalled or canceled. If applying the changes
fails (burst_apply_failed), retry exec.run with the same sessionId to finish the apply. The agent tools offer
bursts when asked: workspaceTools(ws, { burst: true }).
Inbound ports (0.14.0+)
Serve a port of the workspace at its own HTTPS URL: a preview of the app your agent is building, a webhook receiver,
an OAuth redirect, a live view. A request wakes a parked or suspended workspace and is answered once it runs, so the
workspace can sleep between visits. Every port is private: a request carries a port token, a signed link's browser
session, or arrives on the port's callback URL.
await ws.cell().exec.start({ argv: ['python3', '-m', 'http.server', '3000', '--bind', '0.0.0.0'] });
const { url } = await ws.ports.expose(3000);
const { token } = await ws.ports.token(3000);
await fetch(url, { headers: { authorization: `Bearer ${token}` } });
const link = await ws.ports.link(3000, { path: '/dashboard' });
const hook = await ws.ports.createCallbackUrl(3000);
- The server inside the workspace listens on
0.0.0.0 (all interfaces), not only 127.0.0.1.
expose(port) is idempotent: created is true when this call exposed the port; an exposed port keeps its URL, tokens,
links and callback URL. list() returns { port, url, createdAt, callback } for each exposed port.
token(port, { ttlSeconds }) (60-86400): send it as Authorization: Bearer <token>, or as X-Shardflux-Token
when the app reads Authorization itself. link(port, { ttlSeconds, path }) (60-604800 s): anyone with the link can
open the port until it expires, so share it like a password.
createCallbackUrl(port): a URL with a secret in its path for GitHub, Slack, Stripe or an OAuth provider; a request
to <url><rest> reaches /<rest> without a token header. Its secret is shown only once; calling it again replaces
it, revokeCallbackUrl(port) revokes it.
close(port) stops the port's tokens, links and callback URL at once (also when the port is exposed again).
- By id, without reading the workspace:
cloud.workspaces.ports(id).
Reverse tunnels (0.16.0+)
Forward a workspace TCP port to a service on the machine running your SDK:
const tunnel = await workspace.tunnels.reverse({ remotePort: 8081, target: 'localhost:8081' });
try {
console.log(tunnel.stats);
} finally {
await tunnel.close();
}
The guest listener binds to loopback by default, 127.0.0.1 and ::1 (0.26.0+), so guest clients of localhost
connect either way; bindAddress: '0.0.0.0' selects all guest interfaces.
The target is dialed from this machine. TCP binary data, concurrent connections, half-close and resets are preserved:
a reset on either side reaches the other as ECONNRESET (0.20.0+), so a truncated stream never reads as complete;
network reconnection resumes the tunnel while the handle is open. closed reports completion or failure,
sessionId identifies the forwarder, and stats counts connections, bytes in each direction and reconnects.
An open tunnel keeps the workspace awake; close it when callbacks are finished. For persistent workspaces, explicit suspend/resume preserves
an open tunnel. The caller needs exec and files access.
The tunnel selects a streaming transport automatically when pty permission is granted. Choose transport: 'pty' or transport: 'exec' in the SDK, or --transport pty|exec in the CLI, to select it explicitly. The handle reports transport.
Bulk transfers batch writes automatically. Active transfers retain their byte position through network reconnection.
Forward connections (0.27.0+) check the workspace's tunnel helper before they start; concurrent first connects share
one upload, and a reused workspace client checks once per helper version, so later connects add no extra exec.
Reverse listener recovery (0.27.3+): the handle replaces its tunnel helper automatically when a connection's replay is
lost and accepts fresh connections. Keep the handle open until callbacks finish, then call close().
Tunnel session status (0.28.0+): cell.exec.get(id) and cell.pty.get(id) include optional tunnel_replay when the
workspace reports it. The exported TunnelReplayStatus type describes the value; see the
HTTP API reference for the states.
Computer use (0.15.0+)
A desktop in the workspace for agents that operate GUI software: a screen, a mouse and a keyboard. Switch it on for a
workspace (or for every workspace of your template); the platform starts the desktop on the first call that needs it.
const ws = await cloud.workspaces.open({ key: 'agent/42', template: 'python-node-browser', computerUse: true });
const shot = await ws.computer.screenshot();
await ws.computer.act([
{ action: 'left_click', coordinate: [640, 400] },
{ action: 'type', text: 'hello' },
{ action: 'key', text: 'Return' },
], { screenshot: true });
const { url } = await ws.computer.stream();
const frames = await ws.computer.frames({ maxFps: 5 });
for await (const f of frames) render(f.data, f.format);
With the Anthropic API, answer the computer toolset directly:
import { computerToolset } from '@shardflux/sdk';
const computer = computerToolset(ws);
const msg = await anthropic.messages.create({ model: 'claude-opus-5-5', max_tokens: 16000, tools: [computer.definition], messages });
messages.push({ role: 'assistant', content: msg.content });
messages.push({ role: 'user', content: await computer.run(msg.content) });
- The actions are the members of Anthropic's computer toolset, with their parameters:
screenshot, zoom, left_click,
right_click, middle_click, double_click, triple_click, left_click_drag, mouse_move, left_mouse_down,
left_mouse_up, cursor_position, scroll, type, key, hold_key, wait. A batch runs in order and stops at
the first failure; later actions come back skipped.
computer.run(content) sends every computer call of a model turn as one batch and returns one tool_result per call
(each with toolset_name: "computer"), with a screenshot on the last one when the turn did not end with a look.
workspaceTools() includes, for any model while the workspace's computer use is on, a computer tool (one action
per call, answered with a screenshot) and computer_batch (several actions in one call, with each action's result
and one screenshot after them). Both take screenshot: false to skip the image, settle_ms, and format: "jpeg"
with quality for smaller images.
act(actions, { screenshot, settleMs, format, quality }) takes the same options.
- Commands the agent runs see the desktop:
exec of chromium https://example.com & opens the browser on it.
stream({ interactive }) returns a signed link (view only by default; interactive: true lets the viewer use the
mouse and keyboard); stopStream() ends every view. Streaming uses inbound ports.
frames({ maxFps, format, quality }) streams the screen as JPEG (or PNG) images to your own code, with nothing to
expose or embed: one frame each time the screen changes, up to maxFps (1–10, default 5) a second, nothing while it
is still. Each frame has seq, format, width, height, capturedAt and data. The workspace stays awake while
the stream is open, so close() it when your view closes.
- (0.32.5+)
ws.cell().computer.vncUrl({ control }) is the desktop as VNC (GET /computer/vnc, RFB in a
WebSocket with subprotocol binary) and the token to send with it, for noVNC or a viewer of your own. With control: true a person takes over: the agent's input actions wait while the socket is open, then answer the action error
taken_over with a fresh screenshot.
ws.setComputerUse(true | false | null) sets the workspace's own switch (null follows the template);
ws.computerUse reads { enabled, workspace, template, available }. status(), start({ width, height }) and
stop() manage the desktop directly.
Batches, links and action gates (0.16.0+). Computer batches ending in screenshot or zoom return that action's
image without an extra screenshot. computer.stream() reuses a link of the same kind with more than 60 seconds left;
{ fresh: true } mints one, and stopStream() clears the cache. Gate each action with computerToolset(ws, { beforeAction }) or workspaceTools(ws, { computer: { beforeAction } }): return false, a refusal string, or throw to
answer that action with an error and skip the remaining actions. Hooks may be async.
Embed a live viewer (0.16.0+)
Watch the desktop or a port preview inside your web app. Choose 1–10 distinct HTTPS origins, then use the returned URL as the iframe source.
const { url } = await ws.computer.stream({ embed: { origins: ['https://app.example.com'] } });
const preview = await ws.ports.link(3000, { embed: { origins: ['https://app.example.com'] } });
<iframe src="RETURNED_LINK_URL" title="Watch the agent" style="width:100%;height:600px;border:0"></iframe>
The browser session is partitioned by the top-level site. Each origin is exactly a scheme and host with an optional port, such as https://app.example.com:8443. The viewer permits framing by those origins; interactive viewing uses the same allowlist.
Explicit pixel completion (0.17.0+)
async function pressUntilPixels(ws, expectedRGBAHash: string) {
const result = await ws.computer.act([{ action: 'key', text: 'Return' }], {
screenshot: true,
pixelPredicate: { timeout_ms: 1000, regions: [{ region: [0, 0, 1280, 800], sha256: expectedRGBAHash }] },
});
if (!result.pixel_completion?.matched) throw new Error(result.pixel_completion?.reason);
return result;
}
Explicit predicates are enabled when supplied; no environment switch is required. Omitted predicates retain ordinary 250 ms settling.
Require an appended lossless PNG and omit settle_ms. Digests cover row-major RGBA8 bytes including cursor pixels. The exact matched full frame is encoded and returned. A condition already satisfied before input refuses the batch unless allow_pre_satisfied explicitly allows that semantics. Timeout returns no success image; input may already have run, so inspect input results before retrying. Use a known, semantically unambiguous result. A pixel condition cannot certify unpainted backend state or infer completion from focus or quiet.
Exact text entry (0.17.0+)
pasteText replaces the clipboard with UTF-8 text and sends the chosen paste chord. Use shift+Insert for xterm. Ordinary type sends line breaks as Return. Start with all keys released. Clipboard replacement remains in effect after cancellation; see computer use for action results.
Use ws.computer.pasteText(text, { pasteChord: "shift+Insert", screenshot: true }), or act([{ action: "pasteText", text, paste_chord: "ctrl+shift+v" }]). Only choose this action when replacing the clipboard is intended.
Timing and progress (0.6.0+)
Every open, wake and waited lifecycle call is traced. workspace.lastTiming (and err.timing when the call fails)
says where the time went, and formatTiming() prints it:
const ws = await cloud.workspaces.open({ key: 'customer-42/main', template: 'python-node-browser' });
await ws.suspend({ wait: true });
await ws.resume({ wait: true });
console.log(formatTiming(ws.lastTiming!));
The resume then reads, for example:
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
This resume brought the workspace back from its host's local cache, memory and running processes included, in 341 ms on the server and 413 ms end to end.
- client phases are on your monotonic clock: the request (a held open waits on the server,
held), then each
operation state the SDK observed while waiting, with the server's reason (capacity_pending / no_ready_host:
queued to start; running / template_downloading: fetching the template), then reading the workspace and
issuing the first tool token (together).
- server timing comes from the operation itself (one database clock):
queued is creation until it began
running, including any time in capacity_pending; ran is the cell's work (placement, boot or restore, guest
readiness). start / resume from and boot to ready / host … are what the cell reported. A resume that booted
the saved disk instead of restoring memory reads resume from cold_boot: processes restarted (runtime_changed)
(0.11.0+), or (host_lost) (0.13.1+) after the machine the workspace ran on failed.
- outside the server is your total minus the operation's: network, TLS, polling latency, view and token. A large
value with a small server total points at the connection between you and the API, not at the workspace.
- retries lists transient failures the SDK retried (cause and backoff).
Host metric units (0.31.0+): host metric text honors explicit _us and _ms suffixes and historical millisecond phases. Counters, flags, and metrics without a known time unit print as numbers; hostTimingsMs retains the raw server values.
Watch it live with onProgress (on the client for every call, or per call):
const cloud = new Shardflux({
apiKey: process.env.SHARDFLUX_API_KEY!,
onProgress: (e) => {
if (e.type === 'phase') console.error(`${e.action}: ${e.phase}${e.reason ? ` (${e.reason})` : ''} at ${e.atMs} ms`);
if (e.type === 'retry') console.error(`${e.action}: retry ${e.retry.request}: ${e.retry.cause}`);
if (e.type === 'done') console.error(formatTiming(e.timing));
},
});
Events are phase (a phase began), retry and done (with the LifecycleTiming). Tool calls add token traces
(a tool token fetched: initial, expiring or invalidated) and tool events (a busy wait, a stale token
replaced, a retry). queued/ran need an API that reports the operation's started_at; otherwise they are null.
Sessions
A session workspace is discarded when its session ends. The 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 ws = await cloud.workspaces.open({ key: `job-${jobId}`, template: 'python-node-browser', lifetime: 'session' });
try {
await ws.cell().exec.run(['python3', 'job.py']);
} finally {
await ws.close();
}
close() is safe in finally for any workspace. It always aborts the handle's local streams (exec output, PTY
reads and in-flight cell requests); commands keep running. Only for a session does it call
POST /v1/workspaces/{id}/close.
- Disconnecting, a closed WebSocket or an expired token never ends a session.
- A session cannot be suspended (409,
details.reason session_lifetime). Fork it to keep its state. fork()
takes its own lifetime (default persistent).
- Reopening a live key with a different
lifetime is 409 lifetime_mismatch.
Reset, save as template, changes (layered workspaces)
await ws.reset();
const { operation, build } = await ws.saveAsTemplate({ templateSlug: 'acme-dev', description: 'deps installed' });
await cloud.templates.builds.waitForBuild(build.organization_id, build.id);
const page = await ws.changes({ pathPrefix: '/home/user', summary: true });
ws.diskLayout says whether a workspace is layered. A single-disk workspace is refused with 409
legacy_disk_layout. changes() is served by the cell from the running workspace and needs the files tool.
cell().changesAll() follows the pages.
Idle deletion retention (0.16.0+)
Keep one persistent workspace per chat thread and delete it automatically after 14 idle days:
const ws = await cloud.workspaces.open({ key: 'chat/42', template: 'python-node-browser', retention: { delete_after_idle_days: 14 } });
await ws.setRetention({ delete_after_idle_days: 30 });
await cloud.projects.setRetention((await cloud.me()).api_key!.project_id, { delete_after_idle_days: 14, labels: { app: 'chat' } });
await ws.setRetention(null);
cloud.workspace(key, { template, retention }) accepts the same policy. ws.retention shows its effective days
and projected delete_at. All project selector labels must match exactly; a workspace's own policy wins.
Calls and ongoing work postpone deletion. Without a policy, your workspace stays until you delete it.
Workspace upgrade (0.16.0+)
await ws.upgrade({ at: "now", wait: true }) keeps files, installed package files and home while cold starting on the supported current layout and base. at: "next_resume" schedules it for the next resume. Memory and running processes end at the cold start. Read ws.upgradeAvailable and ws.upgradePending for the target.
Workspace integrations (0.12.0+)
const ws = await cloud.workspaces.open({ key: 'qm/project-42', template: 'python-node-browser',
labels: { scope: 'project-42', owner: 'qm' }, idlePolicy: 'never' });
const matches = await cloud.workspaces.list({ labels: { scope: 'project-42' } });
await ws.setLabels({ scope: 'project-42', owner: 'qm' });
await ws.setIdlePolicy('adaptive');
const idle = await ws.idle();
await ws.keepalive(60);
await ws.suspendWhenIdle({ afterSeconds: 2 });
Labels are exact string pairs (at most 50, keys 1–64 and values 0–256 characters); they are metadata, not secrets.
A failed workspace is recoverable: await ws.wake() and ordinary auto-waking calls restart that ID using its existing disk. They do not create a replacement workspace. A failed attempt still raises its operation error; there is no unbounded restart loop.
After await ws.delete({ wait: true }), opening its key creates a new ID. The old ID, tokens and history remain deleted. A key stays reserved while deletion is in progress.
For a background command with a pipe (no PTY):
const cell = ws.cell();
const session = await cell.exec.start({ session_id: 'qm-worker-1', argv: ['cat'], stdin_open: true });
let ack = await cell.exec.input(session.session_id, 'hello\n', { offset: 0 });
ack = await cell.exec.input(session.session_id, '', { offset: ack.offset, close: true });
Input frames are at most 64 KiB. The acknowledged offset counts bytes accepted into the pipe, not bytes consumed by the program. A blocked writer may receive a partial acknowledgement; continue from the returned offset. Retrying the identical last frame is safe. close sends EOF only once the entire frame is accepted. Do not combine stdin_open with the existing one-shot stdin option. The transport writes no separate input log or payload file; program output and full-state snapshots retain their normal persistence. Full-state suspend preserves the pipe. Where a workspace does not offer it, the start is refused with 409 conflict, reason host_feature_unavailable and details.feature: 'exec_stdin', not retryable. Running sessions count as work within the documented idle-command window; long-running services should declare keepalives or choose never.
import { isWorkspaceGone, ShardfluxProtocolError } from '@shardflux/sdk';
ShardfluxProtocolError.source is api or cell for SDK responses (unknown only for an error constructed without a source by older caller code). It remains a protocol failure even when status === 404. Never delete local data based on an HTTP status alone.
File-first workspaces (0.9.0+)
A file-first workspace has no VM between commands. Its state is a versioned file tree under /home/user (tree
revisions 0, 1, 2, ...). Each command runs as an execution: a fresh VM on the latest revision, whose changed files
become the next revision. Nothing else survives an execution (processes, memory, files outside /home/user), so install
dependencies into /home/user (for example a virtualenv) and start servers within the command that uses them. It is
ready as soon as it is opened and is never suspended.
const ws = await cloud.workspaces.open({ key: 'customer-42/repo', template: 'python-node-browser', mode: 'file_first' });
const cell = ws.cell();
await cell.files.write('/home/user/app/main.py', 'print("hi")\n', { createParents: true });
const r = await ws.executions.run(['bash', '-lc', 'cd app && python3 main.py > out.txt && cat out.txt'], { timeoutMs: 600_000 });
r.state;
r.exitCode;
r.stdoutText;
r.changed;
r.treeRevision;
ws.treeRevision;
await cell.files.write('/home/user/app/main.py', 'print("bye")\n', { ifTreeRevision: ws.treeRevision! });
- Executions are idempotent by id.
executionId defaults to a fresh ex-<uuid>; pass your own to make a call safe
to repeat across processes. Network failures and retryable 5xx answers (503 no_execution_host: the execution
cannot be placed right now; the SDK waits Retry-After) are retried with the same id, at most maxRetries (5)
times, and an answer that takes longer than attemptTimeoutMs (300 s) is awaited again with the same id. The cell runs a command once per id: a
repeated call gets the recorded result (r.replayed). The SDK never retries with a new id: a failed or lost
result is returned, and running it again is your decision.
ws.executions.get(id, { waitMs }) reads an execution: pending while it runs, its result when it ended (kept 7
days). Use it after a call that stopped waiting (an aborted signal): an execution runs to completion.
- While an execution runs, other executions and file writes are refused with 409
workspace_busy
(execution_in_progress); the SDK waits them out within transitionTimeoutMs (120 s).
- Only paths under /home/user exist (
outside_tree_root otherwise). Reads, writes, search() and patch() work as on
a processful workspace, on the latest revision.
- Calls a file-first workspace does not have fail with
NotSupportedForModeError before any request: exec sessions
(cell.exec.*), PTY, processes, git, browser, changes(), suspend, resume, snapshot, fork, reset and
saveAsTemplate. On a processful workspace executions and ifTreeRevision are refused the same way.
- Reopening a key with another
mode is 409 mode_mismatch; an account without file-first workspaces gets 422
mode_not_available; a template version without the layered layout 409 layout_unsupported.
Templates: file tree, diff and dev mode
templates.list({ includeUnlisted: true }) and listAll accept the optional unlisted-template filter (0.32.1+). It is omitted by default; opt in on an API that supports unlisted filtering.
const dir = await cloud.templates.files('python-node-browser', 5, { path: '/usr/local/bin' });
const entry = await cloud.templates.fileEntry('acme-dev', 3, '/home/user/.bashrc');
const diff = await cloud.templates.diff('acme-dev', { from: 2, to: 3 });
for await (const d of cloud.templates.diffAll('acme-dev', { from: 2, to: 3 })) console.log(d.change, d.path);
Versions published before file lists answer 409 file_list_unavailable. A version that is still being indexed
answers file_list_indexing (retryable); from 0.27.0, GET and HEAD reads wait its full Retry-After and retry within
admissionTimeoutMs. Template objects and version history stay available meanwhile.
Develop an organization template in a draft: a layered workspace you edit live.
const draft = cloud.templates.draft('acme-dev');
const { workspace } = await draft.create({ base: 'python-node-browser@5' });
await workspace.cell().exec.run(['bash', '-lc', 'npm ci']);
await draft.captureState({ label: 'deps installed' });
const test = await draft.openTestInstance();
await test.close();
const { build } = await draft.publish({ description: 'npm ci' });
await draft.discard();
draft.get() throws 404 draft_not_found when there is no draft. draft.find() returns null instead.
states(), statesAll() and testInstances({ includeEnded }) list the draft's states and test instances. Only
owners, admins and API keys with a tool permission may change drafts; others get 403 template_dev_mode_role.
Build a template from template.yaml (0.7.0+)
A template is a recipe v2: a base, what the build adds (languages, apt/pip/npm packages, files, named build steps) and
the settings a workspace gets when it opens (environment, open-time inputs, start commands, services, defaults).
template.yaml is that document in YAML. A file entry may name a local from path (relative to the file): a folder is
copied 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], requirements: [/home/user/app/requirements.txt] }
files:
- { from: ./app, to: /home/user/app, owner: user }
- { from: ./config/settings.toml, to: /home/user/.config/acme/settings.toml, owner: user, mode: "0600" }
steps:
- { name: install, run: npm ci, user: user, cwd: /home/user/app }
settings:
env: { APP_ENV: development }
inputs:
PROJECT_NAME: { kind: text, required: true }
OPENAI_API_KEY: { kind: secret }
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,
wait: true,
onProgress: (e) => console.log(e.type, e.type === 'build' ? e.build.state : e.from),
});
console.log(build.state, build.template_version, build.provenance.recipe_sha256, uploads);
buildFromFile is Node only (it reads the disk; browsers never load that code). Folders are packed as a
reproducible tar (sorted, mtime 0, no owner names; symlinks must stay inside the folder), the same bytes as the Python
SDK packs, so the same inputs give the same recipe_sha256. Uploads the organization already has are not sent
again. It throws TemplateFileError before any request for a file it cannot read or pack, and TemplateUploadError
when the storage refuses the bytes. A recipe already in memory: templates.buildFromRecipe(doc, { templateSlug, baseDir }). Pass root to refuse local paths outside a directory, and parseYaml to use another YAML parser.
The pieces on their own:
const up = await cloud.templates.uploads.put(bytes, { kind: 'file' });
const dir = await cloud.templates.uploads.putPath('./app');
await cloud.templates.builds.create(orgId, { templateSlug: 'acme-dev', recipe: { schema: 'shardflux.template-recipe.v2',
base: 'ubuntu-24.04@1', build: { files: [{ upload: up.ref, kind: 'file', to: '/etc/acme.conf' }] }, settings: {} } });
const exported = await cloud.templates.versions.recipe('acme-dev', 3);
const langs = await cloud.templates.languages('ubuntu-24.04@1');
const pkgs = await cloud.templates.packages.search('apt', 'ffmpeg', { base: 'ubuntu-24.04@1' });
Test a version before publishing it, with its inputs, then open workspaces with inputs:
const test = await cloud.templates.versionTestInstances.create('acme-dev', 4, { inputs: { PROJECT_NAME: 'demo' } });
console.log(test.startup);
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 failed start command or service fails the open (OperationFailedError, code startup_failed, retryable); the
workspace keeps running so you can inspect it, and workspace.startup names the step, its exit code and output tail.
The next open runs the failed step again. Versions report their settings, platform templates their category
(os or stack), builds the denied_hosts their build network refused (add them to build.network.extra_hosts).
Immutable paths (0.13.0+)
Directories a template declares immutable follow the template: every workspace of it mounts them read-only from the
template's newest published version, so the tools, models or data you ship there reach existing workspaces with your
next version, at their next cold boot or resume. Everything else in a workspace stays its own.
immutable: [/opt/acme]
const t = await cloud.templates.get('acme-dev');
console.log(t.open_version?.immutable);
const ws = await cloud.workspaces.open({ key: 'customer-42/main', template: 'acme-dev' });
console.log(ws.template.version, ws.immutableVersion);
A build without immutable keeps the list of the template's open version, and each new version keeps every path of
it: add paths, never remove them. A build reports the list its version declares in immutable_paths.
Initialized CPU services (0.17.0+)
Template services accept initialized_cpu with compatibility, reset, and timeout_seconds. Use cpu-service-v1 for immutable initialization, or cpu-service-fresh-inputs-v1 with fresh_env_names for values supplied at each open. Declare an unprivileged user and command readiness. Each open runs the reset script and its owned startup hooks before returning. Initialization-dependent and secret inputs use ordinary startup.
Agent tools
workspaceTools(workspace) returns tools with a name, a description, a JSON Schema for the
parameters and an execute function. It takes a Workspace or (0.15.0+) a WorkspaceRef, whose
await ref.tools(opts) builds them from the API key's grants before the workspace exists (see
Workspaces by key). Export them for your model provider and dispatch its tool
calls:
import Anthropic from '@anthropic-ai/sdk';
import { executeToolCall, toAnthropicTools, toOpenAITools, workspaceTools } from '@shardflux/sdk';
const tools = workspaceTools(workspace);
const anthropicTools: Anthropic.Tool[] = toAnthropicTools(tools);
const chatTools = toOpenAITools(tools);
const responsesTools = toOpenAITools(tools, { api: 'responses' });
const output = await executeToolCall(tools, block);
(0.8.0+) The exports type-check as the provider SDKs' own types under strict, with no casts:
Anthropic.Tool[], OpenAI.Chat.ChatCompletionTool[] and, with { api: 'responses' },
OpenAI.Responses.FunctionTool[]. executeToolCall takes a tool_use block's unknown input as it is and validates
it.
Your agent loop and model calls stay in your application; the workspace is the computer the tools
act on.
The tools are exec, read_file, write_file, list_files, search_files and edit_file (0.9.0+), the process,
terminal, git and browser tools, and computer (0.15.0+, while the workspace's computer use is on), filtered by the
tools your key grants. edit_file replaces exact text; when the
model passes no expected_revision it reads the file's revision first, so a change made in between fails the edit
instead of being overwritten. Each call first sends workspace.hint() without waiting for it (hint: false turns
that off, e.g. when you send the hint yourself as the model starts a tool call), except read_file, list_files and
search_files: a sleeping workspace answers them from its disk without waking.
Background commands (0.13.0+). On a processful workspace exec takes background: true for anything that runs
longer than a few minutes (a build, a test suite, a training run, a server): it returns { session_id, state } at
once and the model keeps using the other tools. exec_read reads the command's state, exit code and output (the end
of each stream, or from the next_stdout_offset / next_stderr_offset of the previous read; wait_ms waits for the
exit and returns as soon as it happens), and exec_cancel stops it. timeout_ms (up to a day) sets the longest the
command may run; the workspace stays awake while it does. A burst (burst: 'always') runs in the foreground.
const tools = workspaceTools(workspace);
const { session_id } = (await executeToolCall(tools, { name: 'exec', input: { command: 'make -j8 test', background: true, timeout_ms: 7_200_000 } })) as { session_id: string };
const progress = await executeToolCall(tools, { name: 'exec_read', input: { session_id, wait_ms: 30_000 } });
For a file-first workspace (0.9.0+) the tools are exec and the files tools only: exec runs each command as an
execution and adds execution_id, state, tree_revision and changed to its result, and the process, terminal,
git and browser tools are not offered. workspaceTools(ws, { mode }) builds the definitions without touching the
workspace; onExecution(id) is called with each execution id before it is sent.
Prepare tool input (0.14.0+)
workspaceTools(workspace, { prewake: true }) adds an optional onInputStart() callback to tools that need the VM. Call the matching tool's callback when the model starts streaming its input, then execute the tool normally once its arguments are complete. The callback returns immediately and prepares the workspace in the background. Without prewake, tools have no input-start callback. File reads served from disk and file-first tools have no callback.
const tools = workspaceTools(workspace, { prewake: true });
tools.find(tool => tool.name === toolName)?.onInputStart?.();
Tool-call capture (0.7.0+)
Your harness's tools (web search, SQL, HTTP APIs, MCP servers) run in your application, so their results reach the
model but not the workspace. workspace.captureToolCalls() 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();
for (const block of message.content) {
if (block.type !== 'tool_use') continue;
const output = await capture.run(block, () => myTools[block.name](block.input));
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(output) });
}
Capture is invisible to the harness. A wrapped tool returns the same value, the same promise object and the same
thrown error; a synchronous tool stays synchronous. Nothing capture does throws into your code: write failures,
drops and a throwing transform go to onError and capture.stats. Serialization runs after the call returns and
the writes run in the background.
One line per framework:
| Hand-rolled loop | capture.run(call, () => ...), capture.wrap('name', fn), or capture.record({ tool, input, output, callId }) |
| Shardflux tools | executeToolCall(capture.tools(workspaceTools(workspace)), call) (the call's id is recorded) |
| Vercel AI SDK 7 | generateText({ tools: capture.aiSdk.tools(tools) }), or ...capture.aiSdk.callbacks() (onToolExecutionEnd; { legacy: true } for experimental_onToolCallFinish) |
| Mastra | new Agent({ tools: capture.mastra.tools({ weather }), hooks: capture.mastra.hooks() }) |
| Anthropic tool runner | client.beta.messages.toolRunner({ tools: capture.anthropic.tools([weatherTool]) }) |
| OpenAI Agents JS | const detach = 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' }) |
const result = await generateText({ model, tools: capture.aiSdk.tools({ weather, search }), prompt });
for await (const m of query({ prompt, options: { hooks: capture.claude.hooks(), mcpServers } })) handle(m);
const detach = capture.openaiAgents.attach(runner);
export const lookup = captureTool('lookup', async (id: string) => db.find(id));
await capture.activate(() => handleRequest(req));
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 remembers the
last 10 000 call ids and records each once, so a wrapper and a hook can be combined.
In the workspace (dir defaults to /home/user/tool-calls; point it at a subdirectory, e.g.
/home/user/tool-calls/conv-123, to group runs):
<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 (start time plus a random suffix; 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 that tells the agent where its tool calls are; add it to your system prompt if you want (it is
never injected).
Read-your-writes. Calls through the same client first wait for capture writes recorded before them (bounded by
settleTimeoutMs, 30 s; they never fail because of capture): exec and files calls through workspace.cell(),
workspaceTools, snapshot, fork, suspend, suspendWhenIdle (0.10.0+), saveAsTemplate and close (on the workspace handle and on
cloud.workspaces.*(id)). A lifecycle call's timing shows the wait as a capture_flush phase. delete and reset
drop pending writes. A write to a suspended workspace wakes it (wake: null opts out).
Serverless. Writes finish in the background, so let them finish before the function is frozen:
waitUntil(capture.flush()) on Vercel (or Next.js after(() => capture.flush())), await capture.flush() before
returning on AWS Lambda. flush() and close() never reject; they return { complete, written, failed, dropped }.
Redaction. Nothing is redacted by default (the input came from the model). transform(event) gets a copy of each
call and returns it (changed or not) or null to drop it; if it throws, the call is dropped, never written unredacted.
const capture = workspace.captureToolCalls({
exclude: ['exec'],
transform: (e) => (e.tool === 'crm_lookup' ? { ...e, output: redact(e.output) } : e),
onError: (err) => log.warn(err.kind, err.message),
});
Limits. An output over maxOutputBytes (32 MiB) is cut when it is text or JSON (.part, truncated: true) and
not stored when it is binary (dropped: "too_large"). What pending calls hold in memory (files, index lines, inline
inputs) is bounded by maxPendingBytes (128 MiB): over it the output and a large input are dropped and the call keeps
a small index line with dropped: "queue_full"; the call never waits. At most maxPendingCalls (10 000) calls are
pending: past that, while the workspace takes no writes, a call is not recorded at all (record() returns null,
onError gets queue_full). A write is retried for retryWindowMs (120 s) and then dropped (write_failed).
Observing an async tool marks its promise handled: if your code never awaits a wrapped tool's promise and it rejects,
Node reports no unhandledRejection for it (the error is still in the index; Node has no way to observe a rejection
without handling it). About one write per call: the default limits handle about 100 calls per second per workspace;
raise the pending limits for more. Response, ReadableStream, Node streams and Blob results are never read
(meta.note: "stream_not_captured"). On template v1, captured files are read-only to the agent.
Decide between known options (0.32.5+)
When your agent or app has to understand what a person or another agent meant, and the options are known (which
project, which file, which kind of request), ask the decision model instead of matching words. cloud.router.decide()
answers each question with one of your choices, its confidence and every choice's probability; a low confidence means
ask the person:
const { answers } = await cloud.router.decide({
input: 'The person wrote: "Lets check on our langauge model training progress"\n\nTheir Shardflux projects: status-board (an HTTP status board), site (the marketing site).',
questions: [{
name: 'where',
instructions: 'Where does what the person means most likely live?',
choices: [
{ value: 'status-board', description: 'The status-board project in Shardflux.' },
{ value: 'site', description: 'The site project in Shardflux.' },
{ value: 'their_computer', description: 'Not in Shardflux: probably a folder or running job on their own computer.' },
],
}],
cacheSeconds: 60,
});
answers[0];
Up to 12 questions of 2 to 60 choices each are answered together; cacheSeconds reuses the answer for the same input
and questions (cached: true). A 503 dependency_unavailable with reason
model_unavailable is retryable and sent again after its Retry-After like any read.
Two decisions come ready-made. cloud.router.labels(commands) says what each shell command an agent runs is doing,
for a live status line (LabelCategory: tests, dev_server, typecheck, lint, install, build, read_file,
list_files, search, git, network_probe, screenshot, edit_file, run_script, stop_process, other), and
cloud.router.align({ words, marks }) lines the words a person said up with the pointer marks they made while saying
them ("make this bigger and put it here"):
const { labels } = await cloud.router.labels(['git log --oneline -3', 'pnpm install --frozen-lockfile']);
const { marks } = await cloud.router.align({
words,
marks: [{ id: 'r1', kind: 'rest', t0: 0.5, t1: 1.0, surface: 'preview' }, { id: 'r2', kind: 'rest', t0: 1.9, t1: 2.6, surface: 'preview' }],
});
A mark is a click, drag, circle, rest or pass from t0 to t1. ShardfluxAccount has the same router
across an organization's projects: pass organizationId when you belong to several. Exported types: DecideParams,
DecisionQuestion, DecisionChoice, DecisionsResult, DecisionAnswer, LabelsResult, CommandLabel,
LabelCategory, AlignParams, AlignResult, AlignedMark, SpokenWord, PointerMark and RoutedBy.
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 the workspace receives the secrets
bound to it, plus any the call names in secretRefs.
const project = (await cloud.me()).api_key!.project_id;
await cloud.secrets.create(project, { name: 'OPENAI_API_KEY', value: process.env.OPENAI_API_KEY! });
const workspace = await cloud.workspaces.open({ key: 'customer-42/main', template: 'python-node-browser', secrets: ['OPENAI_API_KEY'] });
await workspace.secrets.get();
await workspace.secrets.set(['OPENAI_API_KEY', 'DATABASE_URL']);
await workspace.cell().exec.run(['python3', 'agent.py']);
- A name that is unknown, or a secret this workspace may not use, is refused with
ShardfluxApiError 422 (details.reason secret_not_available, details.names); nothing
changes. A bound secret must allow the exec and pty tools (the default).
status per bound name: available, not_allowed (its permissions do not cover this
workspace; starts are refused with 403 secret_not_available until fixed) or deleted.
- Deleting a secret removes it from every binding. Forks keep the binding, but secrets limited to
specific workspaces are checked against the fork's own id.
- A bound name that is also passed in
env is refused (422).
cloud.secrets also has list, get, update, rotate, versions, delete,
accessEvents, createOrganization and listOrganization. Organization-wide secrets and access
logs belong to organization owners and admins, so a project API key gets 403 for those.
Account (ShardfluxAccount) (0.9.0+)
ShardfluxAccount does what a person does in the web app, with a user session instead of an API key: sign up, sign
in (with MFA), organizations, projects, API keys, members, invitations, billing, audit, data export and account
deletion. Paying in Stripe Checkout is the step a person does.
import { Shardflux, ShardfluxAccount } from '@shardflux/sdk';
await ShardfluxAccount.register({ email, password, displayName: 'Ada' });
await ShardfluxAccount.verifyEmail('https://app.shardflux.dev/auth/verify-email#token=...');
const { account, result } = await ShardfluxAccount.login({ email, password, onSessionToken: (s) => save(s.token) });
if (result.status === 'mfa_required') await account.auth.completeMfa({ code: '123456' });
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 session token is sent as
Authorization: Bearer sfu_... on /v1. Login completion, auth.stepUp(),
auth.changePassword(), auth.totp.confirm() and auth.totp.disable() rotate it (the old token stops working):
account.sessionToken always holds the current token, every later call uses it, and onSessionToken({ token, expiresAt }) is called (and awaited) with each new one. Save it there. Sessions idle out after 30 days.
- Sensitive calls (exports, deletions, TOTP changes, …) answer 403
step_up_required without a recent password
check: await account.auth.stepUp({ password, code }), then retry. A session still waiting for its second factor
gets 403 mfa_required; an unverified email 403 email_unverified.
- Namespaces:
auth (session, MFA, logout, sessions, step-up, password and email change, totp), organizations
(list, listAll, create, get, entitlements, deletion, delete, exports, workspaces), projects,
apiKeys (create sends an Idempotency-Key, so a retry never makes a second key; toolPermissions defaults to
[]), members, invitations (accept(linkOrToken)), billing, user (account deletion, scheduleDeletion,
cancelDeletion, exports), templates (plus publishVersion and archiveVersion), audit (plus export(orgId, { format: 'csv' | 'ndjson', ...filters }), the text), and usage, secrets, egress, volumes with explicit
organization and project ids; me() and request(). List methods return the API's page { data, next_cursor };
listAll iterates every page.
parseEmailToken(input) returns the token of a link's #token= fragment or ?token= query (else the trimmed
input), and throws for a link without one.
A project key with your session (0.32.3+)
A project API key reaches its own project. A tool that runs as you can give the key client your session as well, and
then reaches every project you work in:
const cloud = new Shardflux({ apiKey, sessionToken: account.sessionToken! });
const ws = await cloud.workspaces.get(idInAnotherProject);
Each call goes with the key. A call the key answers 404 not_found (a workspace or another resource of another project,
or a call that names another project_id) is sent again with the session, and later calls about the same one go with
the session at once. The session's answer is returned as it came, errors included: 401 unauthenticated when it
expired. sessionToken may be a function, read when a call needs it (null: the key's answer stands).
sessionFallbackFetch(fetch, { baseUrl, sessionToken, viaSession }) does the same as a fetch, for raw requests.
Sign up from an agent (0.14.0+)
One call gives a working account, with no browser and no email round-trip:
import { ShardfluxAccount } from '@shardflux/sdk';
const { account, result } = await ShardfluxAccount.signup({
email: 'ada@example.com', acceptTerms: true, onSessionToken: (s) => save(s.token),
});
if (result.access !== 'verified') {
await account.auth.requestEmailCode();
await account.auth.verifyEmailCode('123456');
}
- A
provisional account works at once on the Free plan. The 30-day trial starts when the person signs in with
Google or GitHub, or saves a card: billing.cardCheck(orgId) (0.32.5+) returns the Stripe page (nothing is charged).
verification_required: verify the email first. An address that has an account is 409 conflict
(details.reason email_registered).
acceptTerms: true (0.21.0+, also on register): the person agreed to the
Terms of Service and acknowledged the Privacy Policy;
the new account records it, with termsVersion (the terms' effective date; default: the current version).
Upgrading a plan: a person pays at the Checkout url; the code waits for the subscription.
const checkout = await account.billing.checkout(org.id, { planKey: 'pro' });
console.log(`Pay here: ${checkout.url}`);
const done = await account.billing.waitForCheckout(org.id, checkout.id, { timeoutMs: 15 * 60_000 });
if (!done.subscription_active) console.log(`checkout ${done.status}`);
Opt-in overage and its spend cap (owners and billing members): read the policy, then change it. Every field is
optional (give at least one); ifMatch (the version you read) makes a concurrent change a 409 version_mismatch
instead of overwriting it.
const policy = await account.billing.spendPolicy(org.id);
if (policy.overage_available) {
await account.billing.setSpendPolicy(org.id, { overageEnabled: true, spendCapMinor: 900, ifMatch: policy.version });
}
await account.billing.setSpendPolicy(org.id, { overageEnabled: false });
await account.billing.setSpendPolicy(org.id, { alertThresholdsPercent: [50, 80, 100] });
A refused change 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: the cap cannot go below what overage
already charged this period). Every owner and billing member gets an email when overage is turned on or off or the cap
changes.
Errors
For error codes, retry timing, and plan recovery, see the error reference.
ShardfluxApiError: the API or the workspace refused the request. Fields: status, code,
message, requestId, retryable, details, operationId, retryAfterSeconds, and reason
(details.reason, e.g. not_session, draft_not_found, revision_mismatch; see KnownErrorReason).
OperationFailedError: an awaited operation ended failed or canceled (errorCode,
retryable (0.6.2+), operation, timing). retryable is the operation error's own flag: true for
capacity_unavailable (the start passed its deadline; retry it), false for a definitive failure.
OperationTimeoutError: waiting gave up; the operation continues (operationId, lastState, lastReason,
deadlineAt (0.6.2+) and queuePosition (0.22.0+) while a start is queued, timing).
ShardfluxProtocolError: a response was not the documented shape.
NotSupportedForModeError (0.9.0+), a ShardfluxApiError (409 conflict, reason not_supported_for_mode):
the call does not exist for the workspace's mode; local is true when the SDK refused it without a request.
TreeRevisionMismatchError (0.9.0+), a ShardfluxApiError (409 conflict, reason tree_revision_mismatch):
an ifTreeRevision call found the tree at currentTreeRevision; nothing changed.
CheckoutTimeoutError (0.9.0+): billing.waitForCheckout() gave up; the checkout stays open (checkout,
waitedMs).
Treat unknown error codes and reasons as generic errors: show message, and use retryable.
Plan-derived API budgets (0.27.0+). ShardfluxApiError.rateLimit reports the budget resource, scope, limit,
window, burst, effective plan and workspace capacity. A retryable API request-budget refusal explicitly marked
request_started: false is retried after its full Retry-After, including non-idempotent requests, within
admissionTimeoutMs. Credential-budget errors stay distinct and keep their metadata. Reuse workspace handles and
credentials across calls.
Credential key pacing (0.27.2+). Where your plan sets credential budgets, workspace credentials share organization
and workspace hourly allowances, plus a separate API-key burst. A tool_token_budget refusal can name api_key;
honor Retry-After and reuse valid credentials. It does not promise that lifecycle work has not started, so it never
authorizes replay of an arbitrary POST.
A 402 allowance_exhausted (opens, resumes and forks refused while a CPU-hours or RAM GiB-hours allowance is used up)
carries reason (0.9.0+):
allowance_used | The allowance is used up and overage is off or not on the plan. | Upgrade, or have an owner or billing member turn on overage under Usage & billing; or wait for details.resets_at. |
overage_paused | Overage is on but paused while a plan payment is past due. | An owner or billing member updates the payment method. |
spend_cap_reached | Overage charges reached the spend cap for this billing period. | Raise the cap (up to the plan price) or upgrade under Usage & billing; or wait for details.resets_at. |
details.spend_cap is { cap_minor, effective_cap_minor, charges_minor, currency } (minor units, null when the plan
has no overage). An older API sends no reason. Do not retry these in a loop.
Admission refusals (0.22.0+), 503 host_capacity (the workspace's host is making room to wake it;
details.queue_position) and 429 quota_exceeded (every workspace the plan lets work at once is busy), are retried
for any call within admissionTimeoutMs; isAdmissionRefusal(err) recognises one that outlasted it. Other retryable
429/502/503/504 refusals (for example 503 wake_failed) are retried after their full Retry-After (with up to 20 %
jitter) for reads, searches and calls that carry an Idempotency-Key (writes and patches), within the request's
timeout; other calls surface them with retryable: true and retryAfterSeconds.
A read of a sleeping workspace that its disk cannot answer (409 workspace_not_running with reason
offline_unavailable or offline_budget) wakes the workspace and is retried like any workspace_not_running; 503
offline_changed (the disk changed during the read) is retried and served by the running workspace. 409 conflict
host_feature_unavailable (search or patches are not available for the workspace, details.feature) is neither
retried nor woken: run grep with exec, or read then write the file, instead.
Immutable paths (0.13.0+): a files write under an immutable path is 409 conflict with reason read_only_path
(not retried; write elsewhere, or change the template). A build is refused with 422 validation_failed and reason
immutable_path_removed when its recipe drops a path of the template's open version (details.removed; keep them),
immutable_paths_unsupported_base when its base cannot carry immutable paths (details.base; build on a newer version
of that base), or invalid_path (details.field recipe.immutable[<i>]). A build that fails on them has
failure.code immutable_path_missing (failure.details.path is not a directory in the built filesystem) or
immutable_image_too_large.
Inbound ports (0.14.0+): 403 forbidden with reason inbound_ports_not_available (inbound ports are not enabled
for the organization), 404 not_found with reason port_not_exposed (a token, link or callback URL of a port that is
not exposed: expose it first; details.port), 409 conflict with reason port_limit (close a port first;
details.limit), and 422 validation_failed for a port outside 1-65535 or an option out of range.
Usage and overage
cloud.usage reads the organization's usage (API keys see organization totals and their own project's workspaces):
summary(orgId), series(orgId, params), workspace(workspaceId, params), estimate(orgId), grants(orgId),
spend(orgId) and spendPolicy(orgId).
const s = await cloud.usage.summary(orgId);
if (s.allowance_exhausted) console.log('starts are refused:', s.exhausted_reason);
const cap = s.spend_cap;
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'}`);
}
Opt-in overage (0.10.0+): while an owner or billing member has turned it on, workspaces keep opening and running
past the CPU-hours and RAM GiB-hours allowances (those allowances show cap_state: 'overage'), and the usage past them
is charged on the next invoice until the charges reach the spend cap.
summary(), spend() and estimate() carry spend_cap (type SpendCap): state (unavailable, off,
paused, within_allowance, accruing, warning, reached), cap_minor, effective_cap_minor,
charges_minor, remaining_minor, percent_of_cap, currency, resets_at, lines (per allowance: units_over,
billed_units, rate_minor, amount_minor) and projected_reached_at. summary() and spend() also carry
exhausted_reason.
spendPolicy() returns the settings: overage_available, overage_enabled, overage_state (unavailable, off,
on, paused), spend_cap_minor, spend_cap_min_minor, spend_cap_max_minor, rates and currency.
- An API key only reads them. Owners and billing members turn overage on or off and change the cap in the console,
or with a user session:
ShardfluxAccount.billing.setSpendPolicy() (see
Account).
GPU-hours (0.25.0+): on plans with GPU-hours, summary().gpu (type UsageGpu) reports the period's
used_hours of included_hours, overage_hours past the allowance and billed_overage_hours, with cap_state
(ok, warning, overage, gpu_blocked) and blocked_reason. It is null on plans without them. The meter is
gpu_seconds and the allowance gpu_hours (enforcement gpu_block); with overage on, GPU-hours are a spend_cap
line and a spendPolicy().rates entry like CPU-hours. See /limits for
the allowances and the rate.
const gpu = (await cloud.usage.summary(orgId)).gpu;
if (gpu) console.log(`${gpu.used_hours} of ${gpu.included_hours} GPU-hours used`);
Feedback (0.9.0+)
cloud.sendFeedback() sends immediately to the Shardflux team. It is an explicit manual API and does not show a consent prompt. Agents: send feedback only when your user asks to. 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 raw logs. 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 (0.32.5+; a whole log is fine). category: bug (something failed or behaved wrongly), confusing (an error, doc,
name or output was unclear), missing (a capability, option or template you needed), idea, praise or other
(the default).
context (all optional): agent (who is reporting: your agent's name or a person), workspace, requestId, errorCode,
command (the call that led to it), and client, which defaults to shardflux-sdk-ts/<version>.
- Returns
{ id, receivedAt, duplicate }. The same message from the same key within 24 hours returns the original
with duplicate: true and sends no second email.
- Any API key may send feedback. Signed in with a CLI session instead,
account.sendFeedback({ message, category?, context?, organizationId? }) sends it as the user (ShardfluxAccount; organizationId: one of yours). It is rate
limited per key or user: a ShardfluxApiError with code: 'rate_limited' and retryAfterSeconds. An empty message is validation_failed. The SDK never retries the call.
- Anything shaped like an API key is redacted before the message is stored or emailed; still, leave secrets out.
More of the API
The Shardflux object also has templates (including custom template builds and the template editor), volumes
(shared persistent storage attached to workspaces), secrets (see above), egress (outbound allowlists),
usage, billing, me(), entitlements(orgId), sendFeedback() and request(method, path) for any /v1
route. The package exports the OpenAPI-generated types as well (paths, components,
WorkspaceView, Operation and more).
Volume views (0.19.0+) include kind and measured storage in usage.
Test your integration (0.16.0+)
Use the same client calls in fast, isolated tests with @shardflux/sdk/testing:
import { createFakeShardflux } from '@shardflux/sdk/testing';
const cloud = createFakeShardflux({
onExec: (request) => ({ stdout: `ran ${request.argv.join(' ')}\n`, exitCode: 0 }),
});
const ref = cloud.workspace('acme/test', { template: 'python-node-browser' });
const result = await ref.exec(['python3', '-V'], { sessionId: 'version-check' });
await ref.files.write('/home/user/result.txt', result.stdout);
The fake returns real SDK workspace handles, refs and errors. Workspace lifecycle calls, labels, paginated lists,
files, command output, desktop actions and port lists use an isolated in-memory store. Command results come from
onExec; returning null leaves a command running for cloud.testing.completeCommand(id, sessionId, result).
Unscripted commands and unimplemented routes raise FakeUnsupportedError.
Control state with cloud.testing.setFaults(id, { busy: true, failed: true, legacyLayout: true }),
computerUnavailable, deleted, resizeRefusal (shrink_not_supported or resize_not_available), and
memoryLimitMib (a plan clamp). Set cloud.testing.quota to retained_state or concurrent_workspaces.
cloud.testing.advance(milliseconds) drives idle deadlines; latencyMs and sleep inject request latency.
cloud.testing.inspect(id) returns a copy of the raw workspace view. Each fake has its own state.
Import the testing subpath when building tests; the main SDK entry keeps its existing runtime dependencies.
Version check (0.9.0+)
After the first successful API response of the process, the SDK asks GET /v1/client-versions in the background
(once per process, 3 s timeout, every error ignored; it never delays or fails a call). When this version is outdated
or below the minimum supported version, it emits one warning:
(node:1234) [SHARDFLUX_UPDATE_AVAILABLE] ShardfluxUpdateWarning: @shardflux/sdk 0.27.0 is outdated: 0.27.1 is available. Update: npm install @shardflux/sdk@latest
- Turn it off with
versionCheck: false (on Shardflux or ShardfluxAccount), SHARDFLUX_NO_UPDATE_CHECK=1 (also
true, yes, on) or NO_UPDATE_NOTIFIER=1. Handle it with process.on('warning', (w) => ...)
(w.name === 'ShardfluxUpdateWarning').
- A tool built on the SDK checks its own package instead:
versionCheck: { package: '@acme/tool', version: '1.2.3' }.
- On demand:
await checkClientVersion() returns { status, package, ecosystem, current, latest, minimumSupported, upgradeCommand, releaseNotesUrl, message? } with status current, outdated, unsupported or unknown (the
request failed, or the package is not listed yet). compareVersions(a, b) compares two major.minor.patch versions
(a pre-release sorts first; null when one does not parse).
Compatibility
- The SDK follows the API's
/v1 contract. New fields, enum values and error codes can appear in
any release; ignore unknown fields.
- Breaking changes ship only in minor releases (0.7 to 0.8) and are marked Breaking in the changelog.
SDK_VERSION is exported; requests send User-Agent: shardflux-sdk-ts/<version>.
- Examples in this README, in
examples/ and on shardflux.dev name the version they need. The examples on the
website and in the console are checked against the version published on npm before they ship.
Repositories with a minimum release age
Keep your repository's age policy. Pin an exact version that has aged enough; do not exempt the entire @shardflux/*
scope. Each version counts its age from its own publication: npm view @shardflux/sdk time --json shows publication
times. A targeted exception for an inspected exact release is the repository owner's decision. Earlier versions do not
contain a later release's fixes.
Support and bug reports
Report a bug or
request a feature.
Include the package and runtime versions, a minimal reproduction, expected and actual behavior, and the request ID
or error code when available. Reports are public: leave out secrets and confidential data.
For usage, account, billing, or private support, email shardflux@heliosone.fi.
Report vulnerabilities through private security reporting.
See the support guide for all reporting options.
License
Apache-2.0
Site logins (0.32.5+)
Save sign-ins once for your agents with account.siteLogins on a signed-in ShardfluxAccount.
list({ site?, projectId?, organizationId? }) returns metadata: site, username, scope, TOTP and passkey marks,
provenance and last use. add, update, remove and linkVault manage the store; reveal(id) returns values
on your own device and is audited. Add and update accept password, totp (seed, digits, period and algorithm),
and passkeys; use projectId or conversationId for those scopes, or agents for all your agents.
Keep revealed values outside model context. credentialRequests.create and fill accept an account username
and the passkey field. A user session's credentialRequests.list() covers every project without a project filter.