Python SDK

Python SDK reference for Python 3.10+: workspaces, commands, files, templates, secrets, agent tools, capture and account operations.

Install

Shell
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; the yaml extra adds PyYAML;
  • is typed (py.typed);
  • exports shardflux.__version__ and sends User-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.

Python
from shardflux import workspace  # reads SHARDFLUX_API_KEY

print(workspace("customer-42/main", template="default").exec("python3 -c 'print(40 + 2)'").stdout)  # 42

The 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:

Python
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 went

open() 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.

Python
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. template is 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=False never creates: the first call finds the key's workspace and raises 404 not_found when 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

Python
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

Python
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

Python
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).

Python
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:

Python
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.

Python
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.

Python
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, replaced

Reset, 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):

Python
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 once

expose() 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:

Text
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

Host 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()):

Python
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

Python
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.

Python
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

Python
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:

Python
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 raises OperationTimeoutError (0.18.0+: one queued for capacity is followed until its deadline first); a failed one raises OperationFailedError at 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: True when it resumed or waited, False when the workspace was already running. agent_label and tools (0.5.0+) choose the tool token the wake brings back.
  • (0.5.0+) The wake is one request: a resume with Prefer: wait and 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=False keeps 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, list and search of 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.

Python
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 waking

ws.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

Python
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.

YAML
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}
Python
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 from is 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 with upload: "sha256:<hex>" instead.
  • Refused before any request, as TemplateFileError: from together with upload, a folder with kind: 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 than shardflux.template-recipe.v2.
  • The API validates the rest: 422 validation_failed with details["field"] and details["reason"].
  • Without wait=True the call returns the queued build; on_progress receives pack, upload and build events.

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

Python
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 services

A 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)

Python
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.

Python
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; [] clears

sf.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 ShardfluxApiError 422 (err.reason == "secret_not_available", details["names"]); nothing changes.
  • Binding status per name: available, not_allowed (starts are refused with 403 until fixed) or deleted.
  • (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.

Python
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.

Python
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 (a CaptureError) and capture.stats.
  • @capture_tool (late-bound to the capture made active with with capture.activate():), @capture.tool and capture.wrap(fn) wrap decorated tools. Stack @capture_tool directly 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.

Python
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 once

signup 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() and auth.totp.disable() answer with a new session token and revoke the previous one. The client switches at once and calls on_session_token(token, expires_at); account.session_token is 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 ShardfluxApiError 403 step_up_required until account.auth.step_up(password=..., code=...) (the code only with two-factor authentication on). A session waiting for its second factor gets 403 mfa_required, an unverified email 403 email_unverified. See Errors.
  • API keys. api_keys.create() returns {api_key, secret} and sends an Idempotency-Key, so a retry returns the same key. tool_permissions=None sends [] (a key without workspace tools); API_KEY_TOOL_PERMISSIONS lists every tool.
  • Emailed links. verify_email, confirm_password_reset(token=...), confirm_email_change and invitations.accept take the whole link or its token. parse_email_token(link) returns the token (#token= fragment, else ?token= query) and raises ValueError for an empty input or a link without one.

Upgrading a plan: a person pays in Checkout, and the client waits for the subscription:

Python
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:

Python
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:

Python
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; client defaults to shardflux-py/<version>. Bad arguments raise ValueError / TypeError before any request.
  • Returns FeedbackResult(id, received_at, duplicate); received_at is an aware datetime. duplicate is True when 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: ShardfluxApiError 429 rate_limited with err.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:

Text
shardflux <installed-version> is outdated: <latest-version> is available. Update: pip install --upgrade shardflux
  • Turn it off with SHARDFLUX_NO_UPDATE_CHECK=1 (also true, yes, on; or NO_UPDATE_NOTIFIER set to anything), version_check=False on the client, or warnings.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 a ClientVersionStatus (status, package, ecosystem, current, latest, minimum_supported, upgrade_command, release_notes_url, message) and never raises. status is current, outdated, unsupported or unknown (the request failed, or no published version). compare_versions(a, b) returns -1, 0 or 1 for two major.minor.patch versions (a pre-release sorts before its release) and raises ValueError for 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.

Python
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):

Python
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 allowed

set_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: GET and HEAD, and requests with an Idempotency-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 most max_retries times, after the full Retry-After with up to 20 % jitter (0.18.0+; before, at most 5 s**)**. A wait that would outlast the request's timeout raises the error at once, with its retry_after.
  • Exec and file calls refresh the tool token on a gateway 401 or 409 stale_epoch, and wait out workspace_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 429 quota_exceeded (workspaces working at once, from a tool call, or from the API for an open, resume or fork). Each wait is the Retry-After plus up to 20 % jitter (exponential backoff with full jitter without one), for up to admission_timeout seconds in all (client option, default 120; sf.request(..., admission_timeout=...) for one call; 0 raises the first answer), outside max_retries. on_progress sees each retry with reason, queue_position, retry_after_seconds and waited. Before 0.18.0, only the cell gateway's 429 was retried (0.10.0+), within max_retries.
  • A failed operation (for example capacity_unavailable) is never retried by the client.

Compatibility

  • The client 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.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=False turns 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_files and edit_file. An agent tool call other than read_file, list_files and search_files now also sends a wake hint request in the background, which workspace_tools(ws, hint=False) turns off.
  • 0.9.0 adds Workspace.immutable_version and the immutable paths error reasons. Breaking: Workspace.update_policy is 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 full Retry-After. Changed behaviour: a wait whose timeout passes while its start is capacity_pending keeps waiting until the start's deadline; until_capacity_deadline=False keeps 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+.

Python
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.

View this page as Markdown