Python SDK
Python SDK reference for Python 3.10+: workspaces, commands, files, templates, secrets, agent tools, capture and account operations.
Install
pip install shardflux
pip install 'shardflux[yaml]' # also reads template.yaml (templates.build_from_file)The PyPI package:
- needs Python 3.10 or later and depends only on
httpx; theyamlextra adds PyYAML; - is typed (
py.typed); - exports
shardflux.__version__and sendsUser-Agent: shardflux-sdk-python/<version>.
Tool-call capture integrations have their own extras: shardflux[claude-agent-sdk], shardflux[openai-agents],
shardflux[langchain], shardflux[pydantic-ai], shardflux[crewai].
A feature marked with a version, such as (0.6.0+), is not in earlier releases. Check your version with
python -c "import shardflux; print(shardflux.__version__)".
The Python SDK covers workspaces, exec, files, templates, secrets, tool-call capture, from 0.4.0 agent tool
definitions with terminals, processes, git and browser, from 0.5.0 the account plane
(ShardfluxAccount), and from 0.6.0 the search_files and edit_file agent tools. Volumes, egress policies and
usage are available with sf.request(...) and the HTTP API.
Quick start
Create a project API key in the console (sfk_<key id>_<secret>). An API key is a server credential; keep it out of
client-side code.
from shardflux import workspace # reads SHARDFLUX_API_KEY
print(workspace("customer-42/main", template="default").exec("python3 -c 'print(40 + 2)'").stdout) # 42The key names the workspace: the first call creates it, later calls (from any process) reuse it. See Workspaces by key. The same workspace with every lifecycle step explicit:
from shardflux import Shardflux, format_timing
sf = Shardflux() # reads SHARDFLUX_API_KEY; or Shardflux(api_key="...")
def open_workspace():
return sf.open(key="customer-42/main", template="default")
# 1. Open the workspace (created on first use) and run a command.
ws = open_workspace()
result = ws.exec("python3 -c 'print(40 + 2)'")
if result.exit_code != 0:
raise RuntimeError(f"python3 exited {result.exit_code}: {result.stderr}")
print(result.stdout.strip()) # 42
# 2. Write a file, then suspend the workspace and wait until the suspend has finished.
ws.files.write("/home/user/notes.txt", "hello from Python\n")
ws.suspend(wait=True) # returns once suspended: ws.state == "suspended"
# 3. Open the same key again: the workspace resumes, and the file is still there.
again = open_workspace()
print(again.files.read_text("/home/user/notes.txt")) # hello from Python
print(format_timing(again.last_timing)) # where the resume's time wentopen() waits until the workspace is running. Opening the same key again never resets it: files, installed packages
and running processes are still there. See Workspaces and Lifecycle.
Configuration
| Argument | Environment variable | Default |
|---|---|---|
api_key |
SHARDFLUX_API_KEY |
required |
base_url |
SHARDFLUX_API_URL |
https://api.shardflux.dev |
timeout |
30.0 seconds per request |
|
max_retries |
2 (safe or idempotent requests only) |
|
admission_timeout (0.18.0+) |
120.0 seconds a call refused before it ran keeps being sent again (see Retries and idempotency) |
|
http_client |
a new httpx.Client (pass your own for proxies or custom transports) |
|
user_agent |
shardflux-sdk-python/<version> |
|
on_progress |
none: a listener for the progress of every traced call (see Timing and progress) | |
version_check (0.5.0+) |
SHARDFLUX_NO_UPDATE_CHECK=1 turns it off |
True: warn once per process when this package is outdated (see Update check) |
Shardflux is a context manager (with Shardflux() as sf: ...). close() closes the HTTP client it created and
flushes every tool-call capture of the client. A missing API key raises ValueError.
From 0.9.1 the httpx.Client the client creates keeps an idle connection for 5 minutes, so the call after an
agent's pause between turns reuses it instead of opening a new TCP and TLS connection. A request the client may retry
(a GET, a request with an Idempotency-Key, an upload or a files search) whose connection closes before the response
arrives is sent again at once on a new connection; that resend does not count against max_retries, and its progress
retry event has delay_ms 0. An http_client you pass is used as given.
| Member | What it covers |
|---|---|
sf.workspace(key, template=...) |
(0.11.0+) A workspace named by its key, created by its first call. See Workspaces by key. |
sf.open(...) |
Open a workspace by key (see below). |
sf.workspaces |
Look up, list and wait for workspaces and operations; lifecycle calls by id. |
sf.templates |
Templates, file trees, diffs, builds, uploads, test instances, drafts. |
sf.secrets |
Customer secrets (values are write-only). |
sf.me() |
The API key's organization, project and tool permissions. |
sf.request(method, path, **kwargs) |
Any /v1 route with the client's authentication, retries and error handling. |
ShardfluxAccount (0.5.0+) is a second client, for what a person does in the console: see Account.
Workspaces
Workspaces by key
(0.11.0+) shardflux.workspace(key, template=..., **open_kwargs) (on a client created from SHARDFLUX_API_KEY)
and sf.workspace(...) return a WorkspaceRef without a request. Its first call opens the key: the workspace is created
from template on first use and resumed afterwards. Later calls go straight to the workspace, which wakes on use.
from shardflux import workspace
ws = workspace(f"acme/{thread_id}", template="default", secrets=["GITHUB_TOKEN"])
ws.exec("git clone https://github.com/acme/app && cd app && npm test") # a string runs through bash -lc
ws.files.write("/home/user/notes.txt", "hello\n")
tools = ws.tools() # agent tools, before any VM exists| Member | Meaning |
|---|---|
exec(cmd, **kwargs) |
Workspace.exec(): a string runs through bash -lc, a list as argv without a shell. |
files |
read, read_text, write, list, stat, remove, search, patch..., as on Workspace.files. |
executions |
A file-first workspace's run() and get(). |
tools(**options) |
workspace_tools() for this key, built from the API key's tool permissions (GET /v1/me, once per client) before the workspace exists; computer only with computer_use=True. The first tool call opens the key. |
open() |
The Workspace, for lifecycle calls, ports, computer use and tool-call capture. |
cell(**options) |
The workspace's cell client. |
hint(...) |
Starts the open early in a background thread; afterwards Workspace.hint(). |
key, created, mode |
created: True when this ref's open created the workspace, False when it existed, None before the first call. |
- The keyword arguments are
open()'s.templateis required:"default"is the platform default template. - Calls from other threads while the first open runs wait for it and share it. A failed open is not kept.
create=Falsenever creates: the first call finds the key's workspace and raises 404not_foundwhen it has none.- A workspace deleted under the ref (
is_workspace_gone(err)) fails the call that meets it; the next call opens the key again, which creates a new workspace.
Open a workspace
ws = sf.open(key="customer-42/main", template="python-node-browser", secrets=["OPENAI_API_KEY"])sf.open() creates the workspace from the template's latest published version on first use and reconnects to or
resumes it afterwards. It never resets an existing workspace. A waiting open asks the API to hold the request until the
workspace is ready; the answer carries the first tool token, so the first tool call starts at once.
| Parameter | Default | Meaning |
|---|---|---|
key |
required | Your stable name for the workspace (1-200 characters, no control characters). Keys starting with sf: are reserved. |
template |
required | Template slug; "default" is the platform default template. A new workspace uses its latest published version. |
caps |
none | {"cpu_millis": ..., "memory_mib": ..., "disk_gib": ...}. The ceiling is the lowest of template, cap and plan. "memory_mib" is the most memory the workspace may use. (0.9.0+) "memory_mib_held": what it holds while idle, elastic memory (a shardflux.Caps). "allocation_mode" is deprecated: accepted and ignored. Given caps replace the stored ones. |
agent_label |
none | Attribution label for tool tokens obtained through this handle. |
tools |
all the key permits | Tools to request in tool tokens: exec, files, pty, process, git, browser. |
secrets |
unchanged | Secret names to bind. Sets the binding of a new key and replaces it on an existing key. |
inputs |
unchanged | (0.3.0+) The template version's text inputs, {"NAME": "value"}. |
lifetime |
the version's default, else persistent |
"persistent" or "session". Reopening a live key with another value is 409 lifetime_mismatch. |
mode |
processful for a new key, else the stored mode |
(0.5.0+) "processful" or "file_first" (a file-first workspace, ready at once). Reopening with the other mode is 409 mode_mismatch. |
wait |
True |
Wait until ready. |
timeout |
300.0 |
Seconds to wait. |
on_progress |
none | Progress of this open. |
sf.workspaces.open(...) takes the same arguments plus idempotency_key (default: a fresh key per call),
server_wait (True), poll_interval (0.25) and max_poll_interval (5.0).
Look up workspaces
for w in sf.workspaces.list_all(key_prefix="customer-42/"):
print(w.key, w.state)
page = sf.workspaces.list(limit=50) # page.data, page.next_cursor
same = sf.workspaces.get(ws.id)
by_key = sf.workspaces.get_by_key("customer-42/main") # None when no workspace has the key| Method | Returns |
|---|---|
get(workspace_id) |
Workspace |
get_by_key(key, include_deleted=True) |
(0.2.0+) Workspace or None. Searches every lifetime and purpose; the live workspace wins over tombstones. |
list(state=, desired_state=, key_prefix=, include_deleted=, lifetime=, purpose=, limit=, cursor=) |
Page[Workspace] (data, next_cursor). By default only persistent standard workspaces: pass lifetime="any", purpose="any" for sessions, drafts and test instances. |
list_all(**filters) |
An iterator over every page. |
operations(workspace_id, state=, kind=, limit=, cursor=) |
Page[Operation], newest first. |
get_operation(operation_id) |
Operation |
wait_for_operation(operation_id, timeout=300.0, poll_interval=0.25, max_poll_interval=5.0, server_wait=True, on_progress=None) |
The succeeded Operation. |
The workspace handle
| Property | Meaning |
|---|---|
id, key |
Workspace id and key. |
created |
(0.11.0+) True when the open() that returned this handle created the workspace, False when it reconnected or resumed; None for handles from get(), list() and get_by_key(). |
state, desired_state, ready |
Observed state, desired state (running, suspended, deleted), and whether both are running. |
cell_endpoint |
The workspace's cell gateway. |
lifetime, is_session, idle_timeout_seconds, ended_reason, deleted_at |
(0.2.0+) Session metadata. |
disk_layout, purpose, origin, dev_template_id |
(0.2.0+) Template metadata. disk_layout is legacy or layered. |
immutable_version |
(0.9.0+) The template version whose immutable paths the workspace shows, or None. |
memory, allocation_mode |
(0.9.0+) WorkspaceMemory(allocation_mode, promised_mib, held_mib, plugged_mib) (elastic memory), and "elastic" (or "fixed" for a workspace that holds its whole maximum). |
startup |
(0.3.0+) Start commands and services: state pending, running, ready or failed (with step, service, exit_code, output_tail, reason). None when the version has neither. |
failure |
(0.29.0+) A failed workspace's reason: operation_id, kind, code, message, retryable, details and failed_at, the error of the operation that failed it (for example vm_start_failed with details.host_reason). Set until a start succeeds; None otherwise. |
last_timing |
(0.2.0+) Timing of the last open, wake or lifecycle call. |
storage_recovery |
(0.26.1+) Set while the workspace's files are stored apart from its computer: state stored, reason, copy_id, files_kept True and message. The next resume restarts its processes from those files. None otherwise. |
mode, tree_revision |
(0.5.0+) processful or file_first; a file-first workspace's newest tree revision seen by this handle (from the view, X-Tree-Revision of every cell response and execution results; it never moves back), None for a processful one. |
executions |
(0.5.0+) File-first workspaces: run() and get() (see Executions). |
suspend_request |
(0.6.0+) The pending suspend when idle request, SuspendRequest(requested_at, after_seconds, not_before), as of the last view; None when there is none. |
files, secrets |
File and secret-binding clients. |
data |
The raw view. |
Methods: exec(), refresh(), wait_until_ready(timeout=300.0), inputs(), the lifecycle calls, wake(),
hint() (0.5.0+),
cell(), tokens(), changes(), save_as_template() and capture_tool_calls().
Lifecycle calls
ws.suspend(wait=True) # memory and processes are checkpointed; returns once suspended
ws.resume(wait=True) # or open() the key again
copy = ws.fork("customer-42/experiment") # waits until the fork is running
copy.delete() # tool access ends at once; the key can be reused after deletion finishes| Call | Default | Returns |
|---|---|---|
ws.suspend(wait=False, timeout=300.0, on_progress=None, durable=False) |
requested | Operation; (0.8.0+) durable=True waits for the durable copy (below) |
ws.suspend_when_idle(after_seconds=, idempotency_key=None) |
recorded | (0.6.0+) SuspendWhenIdleResult(suspend_request, operation, workspace); see below |
ws.cancel_suspend_when_idle() |
cancelled | (0.6.0+) The Workspace (idempotent) |
ws.resume(...) |
requested | Operation |
ws.snapshot(label=None, ...) |
requested | Operation |
ws.delete(...) |
requested | Operation |
ws.close(...) |
requested | (0.2.0+) Operation, or None for a persistent workspace |
ws.reset(...) |
requested | (0.2.0+) Operation |
ws.fork(key, caps=None, lifetime=None, wait=True, timeout=300.0) |
finished | the new Workspace |
ws.resize(memory_mib=None, memory_mib_held=None, allocation_mode=None, cpu_millis=None, disk_gib=None) |
finished | (0.10.0+) A ResizeResult (resize a workspace): per resource named, memory, cpu or disk with applies_at (now, resume, next_start), the previous, target and applied values, limit_reason and reason; and caps. allocation_mode is deprecated: accepted and ignored |
ws.resize() and cloud.workspaces.resize() accept at_least=False and wait=True (0.12.0+). Set
at_least=True to grow named resources independently, and wait=False to return the held answer with its pending
operation. ws.caps is a StoredCaps mapping of the caps used at later starts; numeric caps can be None. ws.grants
can be None until allocation grants have been reported.
Without wait a call returns when the change is requested: the operation is usually still queued and the
workspace unchanged. With wait=True it returns when the change has finished, with the succeeded operation and the
handle refreshed. fork() works the other way round: it waits by default; pass wait=False to get the copy as soon as
the fork is requested.
On a file-first workspace, suspend, resume, snapshot, fork, reset and
save_as_template raise NotSupportedForModeError without a request (0.5.0+).
The same calls on sf.workspaces take the workspace id first and wait=False by default:
sf.workspaces.suspend(workspace_id, wait=True). sf.workspaces.fork(workspace_id, key, ...) returns
(operation, workspace).
(0.6.0+) ws.suspend_when_idle(after_seconds=60) is suspend when idle,
for the end of an agent turn: the workspace is suspended once it has been idle for after_seconds (0 to 3600),
counted from the later of its last work and the request. The next tool call or a resume cancels it; a running command,
attached stream or keepalive postpones it. result.operation is the suspend already in progress (then
result.suspend_request is None and nothing is recorded), else None. ws.suspend_request shows a pending request.
Errors: ShardfluxApiError 409 with reason not_running, operation_in_progress, session_lifetime or
workspace_deleted, and 422 validation_failed outside 0..3600. By id: sf.workspaces.suspend_when_idle(workspace_id, after_seconds=60) and sf.workspaces.cancel_suspend_when_idle(workspace_id).
Durable storage
(0.8.0+) A suspend returns as soon as the workspace is sealed on its host, typically in a few hundred ms.
result["durable"] turns True when the checkpoint lands in durable storage, typically within a few seconds
(Durable storage).
from shardflux import durability_of
op = ws.suspend(durable=True) # returns once result["durable"] is True
durability_of(op) # Durability(state="durable", checkpoint_id=..., durable_at=..., ...)| API | Meaning |
|---|---|
ws.suspend(durable=True) |
Implies wait=True; the suspend and the copy share timeout. |
sf.workspaces.wait_for_durable(operation_or_id, timeout=300.0) |
The same wait for an operation you hold, such as a finished fork of a running workspace. Returns at once for an operation already durable. |
is_durable(op) |
result["durable"]; True for a succeeded suspend or fork without the field; else None. |
durability_of(op) |
result["durability"] as Durability (state: pending, durable or lost, checkpoint_id, generation_id, local_commit_at, durable_by, durable_at, local_commit_to_durable_ms, overdue_at, reason); None when absent. |
lost_suspend_of(op) |
A resume's result["lost_suspend"] as LostSuspend (lifecycle); None when absent. |
host_lost_of(op) |
(0.9.1+) A resume's, open's or suspend's result["host_lost"] as HostLost (detected_at, restored_from, restored_checkpoint_id, state_as_of; lifecycle); None when absent. Also last_timing.server.host_lost. |
The errors a durable wait can end with are in Errors.
A failed operation raises OperationFailedError. If timeout passes first, OperationTimeoutError is raised and the
operation keeps running: wait again with sf.workspaces.wait_for_operation(err.operation_id). Waiting asks the API to
hold each poll until the operation changes (at most 20 s per request); against a server that does not, it polls with
backoff (250 ms doubling to 5 s, ±20 % jitter).
Suspend progress and cancel (0.22.0+)
Read a suspend's progress without waking the workspace, and cancel it while the workspace is still preparing it:
op = ws.suspend() # requested
progress = sf.workspaces.suspend_progress(op.id)
if progress.cancelable:
sf.workspaces.cancel_suspend(op.id) # the workspace keeps running as it was| Method | Returns |
|---|---|
suspend_progress(operation_id) |
A suspend's live progress as a SuspendProgress (below), read without waking the workspace. get_operation() of a suspend carries the same progress (Operation.progress). |
request_cancel_suspend(operation_id, idempotency_key=None) |
Cancels a suspend before its capture starts. Returns as soon as the host accepts, with CancelSuspendResponse(operation, progress) (progress.accepted is True); the operation then turns canceled with error_code suspend_canceled. |
cancel_suspend(operation_id, timeout=300, idempotency_key=None, on_progress=None, server_wait=True, poll_interval=0.25, max_poll_interval=5) |
The same, and waits until the operation is canceled with suspend_canceled; returns that Operation. If timeout passes first, OperationTimeoutError is raised and the cancellation continues. |
Once capture has started the suspend completes: both cancel calls raise ShardfluxApiError 409 conflict with
reason suspend_capture_started (details["progress"] has the progress). Wait for the operation, then resume the
workspace. See Errors.
SuspendProgress |
Meaning |
|---|---|
operation_id, state, workspace_state |
The suspend, its operation state and the workspace's state. |
phase |
Where the suspend is, for example preparing_snapshot or capturing. |
phase_started_at, deadline_at |
When the phase began and when the host finishes it at the latest (RFC 3339), or None. |
cancelable |
True while the suspend can still be canceled (phase == "preparing_snapshot"). |
supported |
Whether the phase is reported live for this suspend. |
safe_action |
The next step: wait_for_terminal (wait for the operation), resume (it is suspended), retry_tools (the workspace is running: carry on), inspect_failure (read the operation's error). |
accepted |
On a cancel: True when the host accepted it; else None. |
Start deadlines
A queued open, resume or fork (capacity_pending) has a deadline 15 minutes after it was created. The deadline is
in error["details"]["deadline_at"], on the phase progress event as deadline_at (0.2.1+), and on
OperationTimeoutError.deadline_at when your wait ends first. A start still pending at the deadline fails with
capacity_unavailable: nothing was started, and a suspended workspace stays suspended. OperationFailedError.retryable
(0.2.1+) is True for it. The client does not retry it for you.
(0.18.0+) The deadline moves later while new capacity is starting for the start
(start deadlines). Waits (sf.open(), resume(wait=True), fork(),
wait_for_operation(), wake()) follow a queued start past their own timeout, up to its deadline
(until_capacity_deadline=True, the default; False stops at the timeout). The phase progress events of a queued
start carry queue_position, scale_out (requested while new capacity is starting), expected_ready_by and
retry_after_seconds next to deadline_at, with a new event each time the queue moves. OperationTimeoutError
carries queue_position.
from shardflux import OperationFailedError
try:
ws.resume(wait=True)
except OperationFailedError as err:
if not err.retryable:
raise
print(f"{err.error_code}: nothing changed; retry.")Sessions, reset and changes
(0.2.0+) A session workspace is discarded when its session ends: close(), or the idle timeout
(idle_timeout_seconds, 600 unless the template sets one). The key then opens a new, empty workspace.
with sf.open(key="agent-7/task", template="python-node-browser", lifetime="session") as job:
job.exec("python3 -c 'print(1)'")
# Leaving the block called job.close(). For a persistent workspace close() makes no API call.
ws.reset(wait=True) # layered workspaces: wipe every change, back to the template
page = ws.changes(path_prefix="/home/user", hash=True, summary=True)
for c in page.data:
print(c.change, c.path) # added, modified, metadata, deleted, replacedReset, changes() and save_as_template() need a layered workspace; a legacy one is refused with 409
legacy_disk_layout. A session cannot be suspended (409 session_lifetime); fork it to keep its state.
Inbound ports (0.10.0+)
ws.ports (by id: sf.workspaces.ports(id)) serves a TCP port of the workspace at its own private HTTPS URL
(guide):
port = ws.ports.expose(3000) # ExposedPort(port, url, created_at, callback, created)
token = ws.ports.token(3000) # PortToken(token, expires_at, url); ttl_seconds 60..86400, default 3600
link = ws.ports.link(3000, path="/dashboard") # PortLink(url, expires_at); ttl_seconds 60..604800, default 86400
callback = ws.ports.create_callback_url(3000) # PortCallbackUrl(url, created_at): shown onceexpose() is idempotent; list() returns the exposed ports ordered by port; close(port) stops its tokens, links and
callback URL at once; revoke_callback_url(port) removes the callback URL. Send the token as Authorization: Bearer <token>, or as X-Shardflux-Token when your app reads Authorization itself.
Timing and progress
(0.2.0+) Every open, wake and lifecycle call is traced. ws.last_timing (and err.timing when the call fails) is a
LifecycleTiming dataclass (phases, retries, server, outside_server_ms, ...); format_timing() prints it:
resume 413 ms, succeeded (workspace 01a0ead6-92e5-7d19-ab7a-f565bf4676cb, operation 01a0ead6-bd61-737a-ae88-66e2d01a6e25)
client: request 218 ms → queued 195 ms
server: queued 51 ms, ran 290 ms, total 341 ms; resume from local_cache, boot to ready 211 ms, host disk 0 ms, load 6 ms, ready 62 ms, total 211 ms, after_restore 36 ms
outside the server: 72 msHost metrics on the server line print in their own units (0.27.0+): a …_us span in µs or ms, a …_ms span or a
host phase in ms, counters and flags as plain numbers; server.host_timings_ms keeps the raw values.
Pass on_progress to the client for every call, or per call (sf.open(...), lifecycle calls,
wait_for_operation(), ws.wake(), ws.cell()):
import sys
from shardflux import ProgressEvent, Shardflux, format_timing
def progress(e: ProgressEvent) -> None:
if e.type == "phase":
print(f"{e.action}: {e.phase} ({e.reason}) at {e.at_ms} ms", file=sys.stderr)
elif e.type == "retry" and e.retry:
print(f"{e.action}: retry {e.retry.request}: {e.retry.cause}", file=sys.stderr)
elif e.type == "done" and e.timing:
print(format_timing(e.timing), file=sys.stderr)
sf = Shardflux(on_progress=progress)A ProgressEvent has type (phase, retry or done), action, workspace_id, operation_id, at_ms,
phase, reason, retry, timing and deadline_at, and (0.18.0+) queue_position, scale_out,
expected_ready_by, retry_after_seconds and waited. A retry's RetryRecord (request, attempt, cause,
delay_ms) adds reason, queue_position, retry_after_seconds and waited (0.18.0+). A listener that raises
never breaks the call.
Commands
r = ws.exec("pip install requests && python3 app.py", cwd="/home/user/project", env={"DEBUG": "1"}, timeout=600)
r = ws.exec(["python3", "-V"]) # a list runs as argv, without a shell
print(r.exit_code, r.stdout, r.stderr, r.timed_out, r.ok)ws.exec(cmd, ...) runs a command and waits for it to exit. A string runs through bash -lc; a list runs as argv.
If the connection drops, it resumes the output from byte offsets; it never starts the command twice. Ctrl-C cancels
the command in the workspace.
| Parameter | Default | Meaning |
|---|---|---|
cwd, env, user |
Working directory, an absolute path (default /home/user; the API refuses a relative one with 422 invalid_cwd), extra environment, guest user. |
|
stdin |
str or bytes written to stdin. |
|
timeout |
Seconds, enforced inside the workspace; the result then has timed_out=True. |
|
session_id |
generated | Session id; starting an existing session never runs anything again. |
secret_refs |
Extra secret names injected into this process only. | |
resource_hint |
"auto" |
(0.10.0+) "heavy" gives an elastic workspace its memory before the command starts, "light" starts it at once, "auto" decides from the command. A workspace that sees its maximum from boot already has it. |
max_output_bytes |
1048576 |
Bytes of stdout and of stderr kept in memory. |
on_output |
on_output(stream, chunk) for output as it arrives. |
ExecResult: session_id, exit_code, term_signal, timed_out, canceled, stdout, stderr, stdout_bytes,
stderr_bytes, truncated, reconnects, session, ok (exit_code == 0), and (0.9.0+) memory_grow: the grow
the command's start waited for on a workspace with elastic memory (outcome,
from_mib, want_mib, got_mib, deliver_ms), else None.
(0.6.0+) A command that could not start (a cwd that is not a directory, a program that is not on PATH, an
unknown user) raises ExecStartError: nothing ran, so there is no exit code. Its message is the workspace's reason,
for example The command could not start: working directory "/home/user/app" is not a directory.
from shardflux import ExecStartError
try:
ws.exec(["python3", "main.py"], cwd="/home/user/app")
except ExecStartError as err:
print(err.message, err.session_id)Files
ws.files.write("/home/user/data.bin", b"\x00\x01\x02", create_parents=True)
data = ws.files.read("/home/user/data.bin") # bytes, the whole file
text = ws.files.read_text("/home/user/notes.txt")
ws.files.list("/home/user") # {"entries": [...], "truncated": False}
ws.files.stat("/home/user/notes.txt")
ws.files.remove("/home/user/data.bin")| Method | Returns |
|---|---|
read(path, offset=None, length=None) |
bytes |
read_text(path, encoding="utf-8") |
str |
write(path, data, mode=None, create_parents=None, append=None, idempotency_key=None) |
dict: path, bytes_written, sha256, durable |
read_with_info(path, offset=None, length=None) |
(0.5.0+) FileRead(data, size, revision, served_from): the bytes plus X-File-Size, X-File-Revision (files up to 16 MiB) and X-Served-From. |
list(path, limit=None) |
dict: entries, truncated |
stat(path, revision=False) |
dict; with revision=True (0.5.0+) also revision, the SHA-256 of the content (regular files up to 256 MiB). |
remove(path, recursive=None, if_tree_revision=None) |
None |
search(path, pattern, regex=, case_insensitive=, include=, exclude=, max_matches=, max_file_bytes=, context_lines=) |
(0.5.0+) dict: matches, truncated, stop_reason, files_scanned, served_from. Read-only, retried like a GET. |
patch(path, edits=None, content=None, expected_revision=None, create_parents=None, mode=None, idempotency_key=None, if_tree_revision=None) |
(0.5.0+) dict: path, revision, previous_revision, bytes_written, durable, replacements, file. edits are {"old_text", "new_text", "replace_all"?}, each matching exactly once unless replace_all, all or none. Exactly one of edits and content, else ValueError before any request. Always sends an Idempotency-Key. |
Writes are atomic and durable: they are acknowledged after the file and its directory are fsynced. write also takes
if_tree_revision (0.5.0+), like remove and patch: on a file-first workspace the change applies only at that
tree revision, else TreeRevisionMismatchError. Search and edit files explains search, patches,
revisions and their refusals.
Executions
(0.5.0+) On a file-first workspace, ws.executions runs each command in a fresh VM on the
workspace's files:
run = ws.executions.run("npm test", cwd="/home/user/app", timeout=600)
if run.state != "succeeded":
raise RuntimeError(f"{run.state}: {run.error_reason}") # nothing was published
print(run.exit_code, run.text(), run.changed, run.tree_revision)executions.run(cmd, ...) |
Default | Meaning |
|---|---|---|
cmd |
required | A string runs through bash -lc; a list runs as argv. |
execution_id |
a fresh ex-<uuid> |
The idempotency key (8-128 characters of A-Z a-z 0-9 . _ : -, else ValueError). The same id with the same request returns the recorded result (replayed=True); with another request, 409 execution_id_reused. |
cwd, env, stdin, timeout, user, secret_refs |
As for ws.exec() (cwd under /home/user). |
|
output_limit_bytes |
1 MiB | stdout and stderr are each kept up to this many bytes (at most 16 MiB). |
max_retries |
5 |
Retries with the same id after network failures and retryable 429/502/503/504 answers such as 503 no_execution_host (Retry-After honoured up to 30 s). |
attempt_timeout |
300 |
Longest single wait for the answer, in seconds; then the request is sent again with the same id and joins the running execution (not counted as a failure). |
ExecutionResult: execution_id, state (succeeded, failed, lost), exit_code, term_signal, timed_out,
stdout and stderr (bytes) with text(), stdout_truncated, stderr_truncated, base_revision,
tree_revision, changed (ExecutionChange(path, change, type)), changed_truncated, timings, error and
error_reason, created_at, finished_at, replayed, ok, pending and raw. A failed or lost result is
returned, not raised, and never retried with a new id.
ws.executions.get(execution_id, wait=None) reads an execution: its result, or a pending one while it runs; wait
(seconds) polls until it ended or the time passed. Results are kept for 7 days. new_execution_id() makes an id. On a
processful workspace executions and if_tree_revision raise NotSupportedForModeError without a request, and on a
file-first workspace ws.exec() does (use executions.run()).
Wake on use
(0.2.0+) A tool call (exec, files, changes) 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. The call never runs
twice: the workspace executes nothing it refused.
- The wait is bounded per call by
transition_timeout(default 120 s), shared by the transition waits and at most 3 wakes. A resume or open still pending at the end raisesOperationTimeoutError(0.18.0+: one queued for capacity is followed until its deadline first); a failed one raisesOperationFailedErrorat once. - Following an exec's output never wakes a workspace, so an explicit
suspend()is respected. ws.wake(timeout=120.0)does the same on demand:Truewhen it resumed or waited,Falsewhen the workspace was already running.agent_labelandtools(0.5.0+) choose the tool token the wake brings back.- (0.5.0+) The wake is one request: a resume with
Prefer: waitand the client's agent label and tools, answered once the workspace runs with the view and a tool token, so the refused call is retried at once.resume(wait=True)sends the same request (server_wait=Falsekeeps the polled path). Against an API without the held resume, the client waits for the operation and fetches a token, as before. - (0.5.0+)
read,read_text,read_with_info,stat,listandsearchof a suspended workspace are answered from its disk while a host still holds it, without waking it (served_from == "disk"). Any other call wakes it.
Wake hint (0.5.0+). A running workspace that nobody uses is
parked by its host and woken by the next tool call.
ws.hint(agent_label=None, tools=None, wake=True, wake_timeout=120) tells the host a call is coming, so the wake
starts earlier. The agent tools of workspace_tools() send it when each call starts (0.6.0+); call it yourself
to start earlier, when your model starts writing a tool call. It returns at once with WakeHint(residency, wake): residency is what the host found (resident, frozen, hibernated, restoring; None when the workspace
was not running), and for a suspended workspace wake is a concurrent.futures.Future of the resume started in a
background thread (shared by concurrent hints; nothing needs to wait for it). wake=False only reports. It is never
retried and never waits out workspace_busy.
cell = ws.cell(transition_timeout=30) # give up waking after 30 s
cell.exec_run(["make", "test"]) # resumes the workspace first if it is suspended
no_wake = ws.cell(wake=None) # returns workspace_not_running instead of wakingws.cell(agent_label=None, tools=None, wake=..., transition_timeout=120.0, on_progress=None) returns the lower-level
CellClient: exec_run(argv, ..., timeout_ms=, kill_grace_ms=, max_reconnects=10, cancel_on_interrupt=True),
exec_start, exec_get, exec_cancel, files_read, files_write, files_list, files_stat, files_remove,
changes and request; (0.5.0+) files_search, files_patch, files_stat(revision=), files_read_with_info,
wake_hint, execution_run and execution_get; (0.4.0+) processes_list, processes_signal, pty_open, pty_get, pty_input,
pty_resize, pty_close, pty_attach (the attach WebSocket; read_only=True watches without typing, passive=True without waking the workspace while the terminal is idle), pty_read, git_clone, git_status, git_commit,
browser_screenshot (PNG bytes) and browser_content.
Templates
See Templates and Build a template.
List and inspect
detail = sf.templates.get("python-node-browser") # versions with their settings (0.3.0+)
sf.templates.list(owner="platform").data
sf.templates.files("python-node-browser", 3, "/home/user") # one directory level of a version
sf.templates.file_entry("python-node-browser", 3, "/usr/bin/python3")
sf.templates.diff("my-agent", from_="base", to=2) # .data: path, change, before, after; .summary| Method | Returns |
|---|---|
list(owner=, include_archived=, limit=, cursor=), list_all(...) |
Templates the key's organization can use. |
get(slug, owner=, include_archived=) |
One template: versions, settings, what open resolves to. Each version's immutable is a dict with paths and bytes, or None. |
files(slug, version, path="/", limit=, cursor=, owner=) |
(0.2.0+) One directory of a version's file tree. |
file_entry(slug, version, path, owner=) |
(0.2.0+) One entry. |
diff(slug, from_=, to=, path_prefix=, change=, limit=, cursor=, owner=) |
(0.2.0+) Diff between two versions (from_ may be "base"). |
versions.recipe(slug, version) |
(0.3.0+) The recipe and settings a version was built from, ready to build again. |
languages(base) |
(0.3.0+) Languages a base (<slug>@<version>) offers build.languages. |
packages.search(ecosystem, q, base=, limit=), packages.get(ecosystem, name, base=) |
(0.3.0+) apt, pip or npm packages. apt needs base. |
Build from template.yaml
(0.3.0+) template.yaml is a recipe v2: a base, languages, packages, files, build steps and the settings every
workspace of the template gets (environment, open-time inputs, start commands, services, defaults). Reading YAML needs
pip install 'shardflux[yaml]'; a JSON file needs nothing, and parse_yaml= accepts any parser.
base: python-node-browser@7 # services need a base whose agent runs them
build:
languages:
- id: go
packages:
apt: [jq]
pip:
requirements: [/home/user/app/requirements.txt]
files:
- from: app # a local folder, relative to this file: uploaded as a tar
to: /home/user/app
owner: user
- from: config/settings.toml # a local file, uploaded byte for byte
to: /home/user/.config/app/settings.toml
owner: user
mode: "0600" # quote modes: YAML reads 0600 as a number
settings:
env:
APP_ENV: development
inputs:
PROJECT_NAME: {kind: text, default: demo}
services:
web:
run: python -m http.server 8000
cwd: /home/user/app
ready: {port: 8000}result = sf.templates.build_from_file("template.yaml", template_slug="my-agent", wait=True)
print(result.build["state"], result.build["registration"]["state"]) # "published", "registered" once usable
print(result.build["provenance"]["recipe_sha256"])
for u in result.uploads: # one per local source
print(u["from"], u["sha256"], "uploaded" if u["uploaded"] else "already there")build_from_file(path, template_slug, display_name=, description=, auto_publish=, acknowledged_scan_findings=, organization_id=, wait=False, timeout=1800.0, parse_yaml=, on_progress=, idempotency_key=):
- Each
fromis packed (a folder: a reproducible, uncompressed tar, the same bytes on every machine and in the TypeScript SDK), hashed and uploaded unless the organization already has those bytes; the recipe is sent withupload: "sha256:<hex>"instead. - Refused before any request, as
TemplateFileError:fromtogether withupload, a folder withkind: file, a compressed archive, sockets, FIFOs or devices in a folder, absolute symlinks or symlinks that leave the folder, more than 200,000 entries, more than 5 GiB, a schema other thanshardflux.template-recipe.v2. - The API validates the rest: 422
validation_failedwithdetails["field"]anddetails["reason"]. - Without
wait=Truethe call returns the queued build;on_progressreceivespack,uploadandbuildevents.
The pieces on their own:
| Call | Does |
|---|---|
pack_directory(folder, dest) |
The reproducible tar of a folder; returns PackResult(sha256, size, entries). |
load_template_file(path, parse_yaml=None) |
Reads a template file. |
sf.templates.uploads.put(data, kind, sha256=, size=) |
Uploads bytes, a path or a binary file object unless the organization has them; returns UploadResult(upload, ref, uploaded). |
sf.templates.builds.create(template_slug, recipe, ...) |
Queues a build of a recipe v1 or v2. |
sf.templates.builds.wait(build_id, timeout=1800.0, on_change=None) |
Waits until the build settles; raises TemplateBuildTimeoutError (the build continues). |
sf.templates.builds.get, list, list_all, cancel, log_url, builder_availability |
Builds of the API key's organization (organization_id= to choose). |
Test instances, inputs and startup
with sf.templates.version_test_instances.create("my-agent", 4, inputs={"PROJECT_NAME": "try"}) as test:
test.exec("curl -s localhost:8000")
ws = sf.open(key="customer-42/main", template="my-agent", inputs={"PROJECT_NAME": "acme"})
ws.inputs() # {"PROJECT_NAME": "acme"}
ws.startup # {"state": "ready", ...}; None without start commands or servicesA version test instance (0.3.0+) is a throwaway session on any version, published or not. A failed start command or
service leaves the workspace running with startup["state"] == "failed"; the next open runs the failed step again.
Inputs the version does not declare are 422 input_unknown; a missing required one is input_required.
Dev mode (drafts)
draft = sf.templates.draft("my-agent")
d = draft.create(base="python-node-browser@3")
d.workspace.exec("pip install -r requirements.txt")
state = draft.capture_state(label="deps", wait=True).result["checkpoint_id"]
with draft.open_test_instance(state_id=state) as test: # a disposable copy of that state
test.exec("python3 -m pytest")
draft.publish(state_id=state, description="deps") # the next version
draft.discard()draft.get(), states() and test_instances(include_ended=) read the draft. Drafts and test instances are for
owners, admins and API keys with a tool permission (others get 403 template_dev_mode_role).
ws.save_as_template(template_slug, ...) saves a layered workspace as the next version of an organization template.
Secrets
Values are write-only: no API returns them. Every exec and terminal in a workspace receives the secrets bound to it,
plus any the call names in secret_refs.
import os
project_id = sf.me()["api_key"]["project_id"]
sf.secrets.create(project_id, "OPENAI_API_KEY", os.environ["OPENAI_API_KEY"])
ws = sf.open(key="customer-42/main", template="python-node-browser", secrets=["OPENAI_API_KEY"])
ws.secrets.get() # {"names": [...], "secrets": [{"name", "status", "secret_id", "scope"}]}
ws.secrets.set(["OPENAI_API_KEY", "DATABASE_URL"]) # replace; [] clearssf.secrets has create(project_id, name, value, description=, allowed_workspace_ids=, allowed_tools=), list,
get, update, rotate, versions, delete, access_events, create_organization and list_organization.
Permission arguments you do not pass are left alone (UNSET); None means no restriction. Organization-wide secrets
and access logs belong to owners and admins, so a project API key gets 403 for those.
- An unknown name, or a secret this workspace may not use, raises
ShardfluxApiError422 (err.reason == "secret_not_available",details["names"]); nothing changes. - Binding status per name:
available,not_allowed(starts are refused with 403 until fixed) ordeleted. - (0.14.0+) Tool-call capture sanitizes bound secret values before saving.
Computer use (0.11.0+)
A desktop in the workspace that agents drive with screenshots, clicks and keystrokes; see the computer use guide.
| Call | Does |
|---|---|
sf.open(..., computer_use=True) |
Opens with computer use on (False off, None follows the template). |
ws.set_computer_use(True | False | None), ws.computer_use |
The workspace's switch; {enabled, workspace, template, available}. |
sf.templates.set_computer_use(slug, enabled, organization_id=None) |
The switch for every workspace of one of your templates. |
ws.computer.act(actions, screenshot=, settle_ms=, format=, quality=) |
Runs a batch of Claude's computer toolset actions in order: {results, screenshot?, cursor, display}. |
ws.computer.screenshot() |
A ComputerScreenshot (format, width, height, data bytes). |
ws.computer.status(), .start(width=, height=), .stop() |
The desktop itself. |
ws.computer.stream(interactive=False, ttl_seconds=None), .stop_stream() |
A private link to the live view (ComputerStream: url, expires_at, port, interactive). |
ws.computer.frames(format=None, quality=None, max_fps=None) |
ComputerFrames: the screen as images, an iterator and context manager of ComputerFrame (seq, format, width, height, captured_at, data), one per change; close() ends it. ws.cell().computer_frames() is its WebSocket. |
computer_toolset(ws) |
.definition and .run(content) for Claude's computer_toolset_20260801: one batch per model turn. |
workspace_tools() includes the computer and computer_batch tools while the workspace's computer use is on.
Agent tools
(0.4.0+) Your application keeps the agent loop and the model calls; the workspace is the computer the agent's tools
act on. workspace_tools(ws) returns the same tools as the TypeScript SDK's workspaceTools(), with the same names
and JSON Schemas: 19 from 0.9.0, which adds exec_read, exec_cancel and exec's background for
long-running commands on a processful workspace (17 from 0.6.0, which
adds search_files and edit_file). The
agent tools guide lists them and has a complete loop.
from shardflux import execute_tool_call, to_anthropic_tools, to_openai_tools, workspace_tools
tools = workspace_tools(ws, tools=["exec", "files"])
anthropic_tools = to_anthropic_tools(tools) # Anthropic Messages API
responses_tools = to_openai_tools(tools, api="responses") # OpenAI Responses API (default: Chat Completions)
output = execute_tool_call(tools, block) # a tool_use block, an OpenAI tool call or function_call item, or a dict| Function | Returns |
|---|---|
workspace_tools(ws, tools=None, agent_label=None, prefix="", max_output_bytes=65536, default_cwd=None, wake=..., transition_timeout=120.0, hint=True) |
list[WorkspaceTool]: name, description, parameters, permission, execute(args, tool_call_id=None). Default tools: those of ws.granted_tools, else all six permissions. wake=None refuses instead of resuming. (0.6.0+) hint=True sends a wake hint in the background when a call starts, except for read_file, list_files and search_files; hint=False turns it off. |
to_anthropic_tools(tools) |
[{"name", "description", "input_schema"}] |
to_openai_tools(tools, api="chat") |
Chat Completions tools; with api="responses", Responses API tools |
execute_tool_call(tools, call) |
The tool's result, a JSON-serializable dict. The call's call_id or id is passed on as tool_call_id. |
capture.tools(tools) |
Copies whose calls tool-call capture records with the model's call id. |
execute_tool_call raises ToolArgumentError for an unknown tool, arguments that are not JSON or do not match the
schema, before anything is sent; errors from the workspace (ShardfluxApiError) propagate. Send either back to the
model as an error result. In async code, run them with asyncio.to_thread.
Tool-call capture
(0.3.0+) Your harness runs the model loop and your own tools. Tool-call capture saves every call's input and output
as files in the workspace, so the agent can work on them with jq or pandas, and snapshots and forks keep them.
capture = ws.capture_tool_calls() # one run directory under /home/user/tool-calls
my_tools = {"web_search": lambda query: {"query": query, "results": []}}
with capture.call("web_search", {"query": "weather oslo"}, call_id="toolu_01") as call:
call.output = my_tools["web_search"](query="weather oslo")
# or afterwards: capture.record("web_search", {"query": "weather oslo"}, output, call_id="toolu_01")
capture.flush(timeout=30)- A wrapped tool returns the same object and raises the same exception; sync stays sync, async stays async.
- Writes happen on background threads. Nothing capture raises reaches your code: failures go to
on_error(aCaptureError) andcapture.stats. @capture_tool(late-bound to the capture made active withwith capture.activate():),@capture.toolandcapture.wrap(fn)wrap decorated tools. Stack@capture_tooldirectly under the framework's decorator; the framework still sees the same signature, docstring, type hints and async-ness.
| Framework | Integration |
|---|---|
| Claude Agent SDK | from shardflux.integrations.claude_agent_sdk import capture_hooks; ClaudeAgentOptions(hooks=capture_hooks(capture, merge=my_hooks)) |
| OpenAI Agents SDK | from shardflux.integrations.openai_agents import CaptureRunHooks; Runner.run(agent, "...", hooks=CaptureRunHooks(capture)) |
| LangChain / LangGraph | from shardflux.integrations.langchain import CaptureCallbackHandler; config={"callbacks": [CaptureCallbackHandler(capture)]} |
| Pydantic AI 2.x | from shardflux.integrations.pydantic_ai import capture_capability; Agent(model, capabilities=[capture_capability(capture)]) |
| CrewAI | from shardflux.integrations.crewai import register_hooks; unregister = register_hooks(capture) |
| MCP clients | from shardflux.integrations.mcp import instrument; release = instrument(session, capture, server="github") |
Explicit capture (record, call, wrap, @capture.tool, @capture_tool) always records. Hook integrations record
every tool, Shardflux's own included; narrow them with tools= / exclude= (names, a compiled pattern or a predicate).
Calls are deduplicated by call id (the last 10,000).
The layout, index format and limits are the same as the TypeScript SDK's
(Tool-call capture). Python-specific options of capture_tool_calls():
| Option | Default | Meaning |
|---|---|---|
dir |
/home/user/tool-calls |
Capture directory in the workspace. |
tools, exclude |
none | Tool selection for hook integrations. |
transform |
none | Redact or drop a call (return None); if it raises, the call is dropped. |
max_output_bytes |
32 MiB | Per call. |
max_inline_input_bytes |
64 KiB | Larger inputs go to their own file. |
max_pending_bytes, max_pending_calls |
128 MiB, 10,000 | Bounds on pending writes; nothing ever blocks the tool. |
settle_timeout |
30.0 |
Bound on the read-your-writes wait. |
exit_timeout |
5.0 |
Bound on the atexit flush. |
retry_window |
120.0 |
How long a write is retried. |
wake |
True |
Writes resume a suspended workspace. |
meta |
none | Merged into every index line's meta. |
concurrency |
4 |
Writes in flight per capture. |
on_error |
none | Receives a CaptureError. |
capture.flush(timeout) and await capture.aflush(timeout) wait for everything recorded so far and return True
when done. close() / aclose(), with and async with stop recording and flush. On serverless platforms, flush
before the handler returns. capture.prompt_hint() returns a paragraph for your system prompt.
Account
(0.5.0+) ShardfluxAccount does what a person does in the console, from a script or an agent: register, sign in
(with two-factor authentication), organizations, projects, API keys, members, invitations, billing, spend alerts, the
audit log, exports and deletion, and template publish and archive. It authenticates with a person's CLI session
(sfu_ and 43 characters, sent as Authorization: Bearer on /v1), not with an API key. Paying in Stripe Checkout
is the step a person does.
from shardflux import API_KEY_TOOL_PERMISSIONS, Shardflux, ShardfluxAccount
# An agent's signup (0.10.0+): one call, no browser.
mine, created = ShardfluxAccount.signup(email="me@example.com", accept_terms=True)
# accept_terms (0.17.0+, also on register): the person agreed to https://shardflux.dev/terms and
# https://shardflux.dev/privacy; the new account records it.
if created["access"] != "verified":
mine.auth.request_email_code() # then mine.auth.verify_email_code("123456")
# Password accounts: register, then pass the emailed link (or the token in it).
ShardfluxAccount.register(email="me@example.com", password=password, display_name="Me")
ShardfluxAccount.verify_email("<link from the verification email>")
def save(token: str, expires_at: str | None) -> None:
store_secret("shardflux-session", token) # your storage: each new token revokes the previous one
account, result = ShardfluxAccount.login(email="me@example.com", password=password, on_session_token=save)
if result["status"] == "mfa_required":
account.auth.complete_mfa(code="123456") # or recovery_code="..."
org = account.organizations.create("Acme")
project = account.projects.create(org["id"], "Default")
key = account.api_keys.create(project["id"], "ci-agent", tool_permissions=API_KEY_TOOL_PERMISSIONS)
sf = Shardflux(api_key=key["secret"]) # the sfk_... secret is shown oncesignup answers (account, result): result["access"] is verified, provisional (creates organizations, projects and API keys before the email
is verified; workspace compute still requires the signup trial or a paid plan) or verification_required.
Later, ShardfluxAccount() reads SHARDFLUX_SESSION_TOKEN (and SHARDFLUX_API_URL). A token that does not have the
session shape raises ValueError before any request. The client is a context manager (close()), and takes
base_url, on_session_token and version_check like Shardflux.
| Namespace | Methods |
|---|---|
| class methods (no session) | signup (0.10.0+), register, verify_email, request_password_reset, confirm_password_reset, confirm_email_change, login |
auth |
session, complete_mfa, logout, logout_all, sessions, revoke_session, step_up (password=), change_password, change_email, resend_verification, request_email_code and verify_email_code(code) (0.10.0+), totp.enroll, totp.confirm, totp.disable, totp.regenerate_recovery_codes |
organizations |
list, list_all, create, get, entitlements, deletion, delete(id, confirmation=slug), exports.create, exports.get, exports.download, workspaces |
projects |
list, list_all, create, get |
api_keys |
list, create(project_id, name, tool_permissions=..., expires_at=...), revoke |
members |
list, update(org_id, user_id, role=...), remove |
invitations |
list, create(org_id, email, role=...), revoke, accept(link_or_token) |
billing |
catalog, subscription (with trial (0.28.3+)), checkout, checkout_status, wait_for_checkout, card_check, card_check_status, wait_for_card_check (0.28.3+), portal, invoices, spend_policy, set_spend_policy |
user (the signed-in person) |
deletion, schedule_deletion(confirmation=email), cancel_deletion, exports.create, exports.get, exports.download |
templates |
publish_version(org_id, slug, version), archive_version(...) |
audit |
list, list_all, export(org_id, format="ndjson" | "csv", ...) (the text) |
secrets |
The same API as sf.secrets, with the person's permissions. |
Plus account.me() and account.request(method, path, ...). Lists return a Page (data, next_cursor); other
methods return the API's JSON as a dict (downloads and exports as text). Organization and project ids are always
explicit.
- The token rotates. Login,
auth.complete_mfa(),auth.step_up(),auth.change_password(),auth.totp.confirm()andauth.totp.disable()answer with a new session token and revoke the previous one. The client switches at once and callson_session_token(token, expires_at);account.session_tokenis always the current one. A session lasts 30 days from its last use and at most 90 days. - Step-up. Exports, deletions, email and two-factor changes raise
ShardfluxApiError403step_up_requireduntilaccount.auth.step_up(password=..., code=...)(the code only with two-factor authentication on). A session waiting for its second factor gets 403mfa_required, an unverified email 403email_unverified. See Errors. - API keys.
api_keys.create()returns{api_key, secret}and sends anIdempotency-Key, so a retry returns the same key.tool_permissions=Nonesends[](a key without workspace tools);API_KEY_TOOL_PERMISSIONSlists every tool. - Emailed links.
verify_email,confirm_password_reset(token=...),confirm_email_changeandinvitations.accepttake the whole link or its token.parse_email_token(link)returns the token (#token=fragment, else?token=query) and raisesValueErrorfor an empty input or a link without one.
Upgrading a plan: a person pays in Checkout, and the client waits for the subscription:
from shardflux import CheckoutTimeoutError
checkout = account.billing.checkout(org["id"], "developer")
print("Pay here:", checkout["url"])
try:
done = account.billing.wait_for_checkout(org["id"], checkout["id"], timeout=900) # polls every 2 s
print(done["subscription_active"]) # False when the checkout expired, was canceled or failed
except CheckoutTimeoutError as err:
print("still open:", err.checkout["url"])
account.billing.set_spend_policy(org["id"], [50, 80, 100]) # usage alerts at 50, 80 and 100 %An organization that already has a subscription gets 409 conflict with err.reason == "subscription_exists" from
checkout: account.billing.portal(org_id)["url"] is where plans change.
Feedback
sf.send_feedback(message, category=..., context=...) sends immediately to the Shardflux team. It is an explicit manual API and does not show a consent prompt. Agents: report only when your user asks to send feedback. Show the exact message and context first, then obtain explicit authorization. A failure or request for a feature is not consent. Leave out private data, secrets and code the user did not mean to share; paraphrase, never paste transcripts. Tell the user what was sent.
Preview a report without sending:
feedback = {
"message": "files.read_text failed; expected the file written by the last exec",
"category": "bug",
"context": {"workspace": "acme/demo", "request_id": "req-example", "error_code": "not_found"},
}
print(feedback)After the user explicitly approves that message and context, send it in a separate action:
sf.send_feedback(**feedback)message: any length after trimming, not empty (0.28.3+).context(every field optional text):agent,client,workspace,request_id,error_code,command,page;clientdefaults toshardflux-py/<version>. Bad arguments raiseValueError/TypeErrorbefore any request.- Returns
FeedbackResult(id, received_at, duplicate);received_atis an awaredatetime.duplicateisTruewhen the same key sent the same message in the last 24 hours: you get the original back and nothing is sent twice. - With a CLI session instead of an API key,
account.send_feedback(..., organization_id=...)(ShardfluxAccount) sends it as the signed-in user, optionally about one of your organizations. - Rate limited: 10 per 10 minutes and 50 per day per key or user, 200 per day per organization:
ShardfluxApiError429rate_limitedwitherr.retry_after(seconds). The call is never retried automatically. - Anything shaped like an API key, token or private key is redacted before the message is stored or emailed.
Update check
(0.5.0+) After the first successful request of a process, Shardflux or ShardfluxAccount checks in a
background thread whether this package is current: the latest release from PyPI (0.18.0+; SHARDFLUX_PYPI_URL names
another index) and the oldest supported one from GET /v1/client-versions (3 s timeout each, every error ignored; it
never slows a call). It emits one ShardfluxUpdateWarning (a UserWarning) when this package is outdated or no longer supported:
shardflux <installed-version> is outdated: <latest-version> is available. Update: pip install --upgrade shardflux- Turn it off with
SHARDFLUX_NO_UPDATE_CHECK=1(alsotrue,yes,on; orNO_UPDATE_NOTIFIERset to anything),version_check=Falseon the client, orwarnings.filterwarnings("ignore", category=ShardfluxUpdateWarning). - A tool built on this SDK checks its own package instead, once per process too:
version_check={"package": "my-tool", "version": "1.2.0"}. - On demand,
check_client_version(base_url=, package=, version=, ecosystem=, http_client=, timeout=, registry_url=)returns aClientVersionStatus(status,package,ecosystem,current,latest,minimum_supported,upgrade_command,release_notes_url,message) and never raises.statusiscurrent,outdated,unsupportedorunknown(the request failed, or no published version).compare_versions(a, b)returns -1, 0 or 1 for twomajor.minor.patchversions (a pre-release sorts before its release) and raisesValueErrorfor anything else.
Errors
All errors derive from ShardfluxError.
| Class | When | Attributes |
|---|---|---|
ShardfluxApiError |
The API or the workspace refused the request. | status, code, message, request_id, retryable, details, operation_id, retry_after, reason (details["reason"]), source |
ExecStartError |
(0.6.0+) ws.exec() / exec_run(): the command could not start. A ShardfluxApiError with code conflict and reason exec_failed_to_start. |
session_id, session, details["error"] (the workspace's reason) |
OperationFailedError |
An awaited operation ended failed or canceled. |
operation, operation_id, error_code, retryable (0.2.1+), workspace_active (0.8.0+: a suspend when idle canceled workspace_active; the workspace keeps running**)** |
OperationTimeoutError |
A wait gave up; the operation continues. | operation_id, workspace_id, last_state, last_reason, deadline_at (0.2.1+), queue_position (0.18.0+), waited, durable (0.8.0+: the wait was for a durable copy, which continues**)** |
DurabilityLostError |
(0.8.0+) A durable wait found durability.state lost (Errors). |
operation, operation_id, workspace_id, durability |
ShardfluxProtocolError |
A response was not the documented shape. | status |
TemplateFileError |
(0.3.0+) A template file or local path cannot be used; nothing was sent. | path |
TemplateUploadError |
(0.3.0+) The storage refused an upload. | status, code |
TemplateBuildTimeoutError |
(0.3.0+) A build wait ran out; the build continues. | build_id, last_state |
ToolArgumentError |
(0.4.0+) A tool call named an unknown tool or its arguments do not match the schema; nothing was sent. Also a ValueError. |
tool, issues |
CheckoutTimeoutError |
(0.5.0+) billing.wait_for_checkout ran out of time; the checkout stays payable until it expires. |
checkout_id, last_status, checkout, waited |
CardCheckTimeoutError |
(0.28.3+) billing.wait_for_card_check ran out of time; the card check stays open until it expires. |
card_check_id, card_check, waited |
NotSupportedForModeError |
(0.5.0+) A ShardfluxApiError (409 conflict, not_supported_for_mode): the call does not exist for the workspace's mode. |
mode, operation, local (refused without a request) |
TreeRevisionMismatchError |
(0.5.0+) A ShardfluxApiError (409 conflict, tree_revision_mismatch): an if_tree_revision change found the tree at another revision; nothing changed. |
current_tree_revision |
Each has timing (0.2.0+) when a traced call failed with it.
from shardflux import ShardfluxApiError
try:
sf.open(key="customer-42/main", template="python-node-browser")
except ShardfluxApiError as err:
print(err.code, err.reason, err.message, err.request_id, err.retryable)503 host_capacity (the workspace's host is making room to wake it) is retried for any call within
admission_timeout (0.18.0+). 503 wake_failed (a parked workspace could not be woken) is retried after
Retry-After for reads, searches and calls with an Idempotency-Key. 409 host_feature_unavailable is neither
retried nor woken. ShardfluxApiError.tree_revision (0.5.0+) is a file-first refusal's X-Tree-Revision.
Treat unknown codes and reasons as generic errors: show message, and use retryable. The list is in
Errors.
A 402 allowance_exhausted (opens, resumes and forks refused while a CPU-hours or RAM GiB-hours allowance is used up)
has a reason (0.6.0+, in KnownErrorReason): allowance_used (upgrade, or turn on
overage), overage_paused (a plan payment is past due) or spend_cap_reached (raise the
spend cap or upgrade), and details["spend_cap"] (cap_minor, effective_cap_minor, charges_minor, currency).
Do not retry these in a loop.
A feature the plan does not include is 402 entitlement_required with reason not_included (0.21.0+, in
KnownErrorReason) and details["plan"], for example the GPU (GPU needs the Startup or Scale plan.): upgrade.
GPU-hours never refuse starts: once they are used up, only the GPU stops, with the three reasons
above, and workspaces keep running.
Overage with a user session
(0.6.0+) Owners and billing members turn opt-in overage on and set its spend cap under
Usage & billing in the console, or with ShardfluxAccount, the client for a user session (sfu_... from a
sign-in; an API key gets 403):
from shardflux import ShardfluxAccount
with ShardfluxAccount() as account: # the session token from SHARDFLUX_SESSION_TOKEN
policy = account.billing.spend_policy(org_id)
# overage_state: unavailable | off | on | paused; the cap range: spend_cap_min_minor .. spend_cap_max_minor (cents)
if policy["overage_available"]:
account.billing.set_spend_policy(org_id, overage_enabled=True, spend_cap_minor=900, if_match=policy["version"])
account.billing.set_spend_policy(org_id, overage_enabled=False) # always allowedset_spend_policy(org_id, alert_thresholds_percent=None, *, overage_enabled=None, spend_cap_minor=None, if_match=None) takes at least one of the first three (ValueError otherwise). The cap is at least $1, at most the
plan price (spend_cap_max_minor), and not below what overage already charged this period. A refusal raises
ShardfluxApiError 422 validation_failed with reason overage_unavailable, spend_cap_required,
spend_cap_below_minimum, spend_cap_above_plan_price (details["max_minor"]) or spend_cap_below_charges
(details["charges_minor"]); with if_match (the policy version, or "*"), a concurrent change raises 409
conflict version_mismatch. See this period's overage charges
with shard usage.
Retries and idempotency
- Requests are retried only when a retry cannot duplicate an effect:
GETandHEAD, and requests with anIdempotency-Key. The client sends a fresh key with opens, lifecycle calls, creates and file writes. - Retried failures: network errors, and 429, 502, 503 or 504 responses whose error is
retryable, at mostmax_retriestimes, after the fullRetry-Afterwith up to 20 % jitter (0.18.0+; before, at most 5 s**)**. A wait that would outlast the request'stimeoutraises the error at once, with itsretry_after. - Exec and file calls refresh the tool token on a gateway
401or409 stale_epoch, and wait outworkspace_busy. - Calls wait their turn (0.18.0+). A call refused before it ran because room is being made for it is sent again,
any call, exec and terminal starts included: 503
host_capacity(the workspace's host is making room to wake it) and 429quota_exceeded(workspaces working at once, from a tool call, or from the API for an open, resume or fork). Each wait is theRetry-Afterplus up to 20 % jitter (exponential backoff with full jitter without one), for up toadmission_timeoutseconds in all (client option, default 120;sf.request(..., admission_timeout=...)for one call;0raises the first answer), outsidemax_retries.on_progresssees each retry withreason,queue_position,retry_after_secondsandwaited. Before 0.18.0, only the cell gateway's 429 was retried (0.10.0+), withinmax_retries. - A failed operation (for example
capacity_unavailable) is never retried by the client.
Compatibility
- The client follows the API's
/v1contract. New fields, enum values and error codes can appear in any release; ignore unknown fields. - Breaking changes ship only in minor releases (0.2 to 0.3) and are marked Breaking in the changelog.
- 0.2.0 changed behaviour: a tool call on a suspended workspace resumes it instead of raising
workspace_not_running.ws.cell(wake=None)restores the old behaviour. - 0.5.0 adds
ShardfluxAccount, the update check, file search and patches, the wake hint and file-first workspaces. Its changes in behaviour: the background update check (one request per process;version_check=Falseturns it off), a wake of a suspended workspace is one held resume, and reads of a suspended workspace are served from its disk without waking it. - 0.6.0 adds the agent tools
search_filesandedit_file. An agent tool call other thanread_file,list_filesandsearch_filesnow also sends a wake hint request in the background, whichworkspace_tools(ws, hint=False)turns off. - 0.9.0 adds
Workspace.immutable_versionand the immutable paths error reasons. Breaking:Workspace.update_policyis gone, as it is from the API. - 0.18.0 sends calls refused before they ran again for any call, within
admission_timeout, and waits the fullRetry-After. Changed behaviour: a wait whosetimeoutpasses while its start iscapacity_pendingkeeps waiting until the start's deadline;until_capacity_deadline=Falsekeeps the previous behaviour.
Integration controls (0.8.0+)
sf.open(..., labels={"scope": "project-42"}, idle_policy="never") sets metadata and policy. sf.workspaces.list(labels={"scope": "project-42"}) filters exact matches. Handles expose labels, set_labels(), idle(), keepalive(seconds) and set_idle_policy() (None restores inheritance).
cell.exec_start({"argv": ["cat"], "stdin_open": True}) opens a pipe; cell.exec_input(id, data, offset=0, close=False) returns acknowledged offset and closed. Maximum 64 KiB per frame; continue from partial acknowledgements. Sending the identical last frame again cannot duplicate bytes. close=True sends EOF only once the frame is fully accepted. Requires guest exec_stdin.v1.
ShardfluxProtocolError.source records api/cell; is_workspace_gone(error) recognizes only explicit API tombstones, never a bare or scoped 404. Failed workspaces recover through wake/resume without replacing the ID or disk.
Prepare streamed tool input (0.10.0+)
Pass prewake=True to workspace_tools() to expose on_input_start callbacks on VM tools. Call the matching callback when the model starts the tool input; it returns immediately and prepares the workspace in a background thread. Default tools, offline reads and file-first workspaces start no prewake thread.
Turn and integration helpers (0.12.0+)
with ref.turn(): ... and async with ref.turn(): ... (also ws.turn()) hint in the background and request idle
suspension on exit, including errors. after_seconds defaults to 0. Async contexts offload lifecycle requests;
ordinary exec and file methods remain synchronous. computer.stream(fresh=True) remints a link; otherwise a
link of the same kind with more than 60 seconds left is reused. Computer tool adapters accept before_action
policies; a refusal becomes that action's error and skips the rest.
Exec and execution results expose exit_code_posix: timeout 124, canceled 130, a nonnegative exit code,
128 + signal, otherwise 1. shardflux.FileNotFoundError inherits builtin FileNotFoundError and ShardfluxApiError.
Environment HTTP(S)_PROXY and NO_PROXY are honored. SHARDFLUX_BASE_URL aliases SHARDFLUX_API_URL; the latter wins.
Upgrade in place (0.12.0+)
ws.upgrade(at="now", wait=True) keeps files and installed package files through a cold start.
at="next_resume" schedules it. Read ws.upgrade_available and ws.upgrade_pending for the target.
Also cloud.workspaces.upgrade(id, at="now"). Memory and running processes end at the cold start.
Volumes, like and tunnels (0.14.0+)
All client additions here require Python shardflux 0.14.0+.
ws = sf.open(key="acme-web/review", like="acme-web/main")like inherits an exact template version, sizes, secrets, inputs and mounts for a new key.
Explicit options override inheritance; an existing key keeps its disk and template. volumes maps absolute mount
paths to names or {"name": name, "mode": "ro"}. An explicit {} detaches all and omission preserves mounts.
Waited opens continue mount operations under one timeout. sf.volumes.open(name=, quota_gib=) creates or reuses
named storage; new storage defaults to 1 GiB. Account limits apply to the live total.
ws.tunnels.forward(remote_port=, connection=, transport=) forwards a caller-owned guest-loopback stream
(127.0.0.1, then ::1 when nothing listens there; 0.22.0+); close its handle when done. Reverse and forward tunnels pass half-close and resets through: a reset on either side
reaches the other as ConnectionResetError (0.16.0+).
Tunnel session status (0.24.0+): exec_get(id) and pty_get(id) preserve optional tunnel_replay when the guest reports it. See session status for the states.
Browser sessions
sf.browser_profiles.list(project_id=), .put(name, scope=, sites=, project_id=) and
.remove(name, site=, project_id=) manage write-only selected-site browser sessions. Scope is project or
{"workspace_id": id}; sites carry Playwright cookies and origin/local-storage state. Reads return metadata.
Stored encrypted with AWS KMS; only this project's workspaces can load it, and every load is logged.
Site logins (0.28.3+)
A signed-in ShardfluxAccount exposes site_logins.list, add, update, remove, reveal and link_vault.
list(site=..., project_id=..., organization_id=...) returns metadata. add(site, username=..., password=..., totp=..., passkeys=..., scope=...) saves a login; project_id or conversation_id selects those scopes, and
agents serves all your agents. update(login_id, **changes) updates scope, metadata or values.
reveal(login_id) returns the password, current TOTP code and seed, plus passkey count, on your own device;
it is audited and requires a user session. Keep the result outside model context.
link_vault(login_id, vault, item) records a local copy.
credential_requests.create and fill accept account= and the passkey field. The account's
credential_requests.list() lists requests across projects; project_id narrows it.
Account creation requests (0.29.0+): cloud.needs_you.answer(id, decision="create_account") asks the requesting agent to create the account and store the credentials securely. credential_requests.create(..., conversation_id=...) binds the request to its chat. Repeated requests read creation_requested.