Skip to main content

shardflux

Historical template file lists: reads queue checksum-verified reconstruction when a cold list is absent from the SQL cache. TypeScript SDK 0.27.0+ / Python 0.23.0+ wait full Retry-After for retryable API 409 file_list_indexing on GET/HEAD within the admission timeout. Template objects and version history stay available.

Plan-derived API budgets (0.23.0+): ShardfluxApiError.rate_limit reports the resource, scope, limit, window, burst, effective plan and workspace capacity. A retryable API request-budget refusal explicitly marked request_started: false is retried after its full Retry-After, including non-idempotent requests, within admission_timeout. Credential-budget errors remain distinct and keep their metadata. Reuse workspace handles and credentials across calls.

Exec survives a dropped connection (0.23.0+): a lost start response is retried with the same session ID and reattaches to the command that already started, and a cut-off JSON answer is retried within the same safe retry boundary.

Suspension controls arrive in 0.22.0 (next coordinated release). cloud.workspaces.suspend_progress(operation_id) returns SuspendProgress with the actual phase, host deadline and cancelable flag, without waking the VM. request_cancel_suspend(operation_id) returns asynchronous host acceptance; cancel_suspend(operation_id, timeout=300) waits for confirmed suspend_canceled recovery of the original VM. Other failures remain errors. Cancellation is refused outside validated preparation, including budget-paused/shared-volume preparation and capture. No reset, replacement or provider turn is launched. After a completed suspension, resume normally; unavailable progress remains explicit.

Remembered sign-in scopes (0.17.0+)

credential_requests.remember(workspace_id, site, credential, scope="machine") saves an encrypted browser-fill credential at the chosen level: conversation, project or machine. forget accepts the same keyword. Conversation credentials take precedence over project credentials, then the account’s machine credentials. Omitting scope keeps the project default.

Python client for Shardflux: serverless VMs for AI agents.

Open a persistent workspace by key, run commands in it, read and write its files, suspend it when idle, resume it later with its disk and memory intact, and fork it. Give your own agent loop the workspace as its computer with workspace_tools() (0.4.0+).

Compatibility. The API is versioned (/v1). Breaking changes ship only in minor releases and are marked Breaking in the changelog (see "Compatibility").

Versions. This README describes 0.7.0. Anything marked (0.2.0+) is not in 0.1.0, (0.2.1+) not in 0.2.0, (0.3.0+) not in 0.2.1, (0.4.0+) not in 0.3.0, (0.5.0+) not in 0.4.0, (0.6.0+) not in 0.5.0, and (0.7.0+) not in 0.6.x; the changelog (CHANGELOG.md, shipped in the source distribution and in the wheel's metadata) lists what each version added. Check yours with python -c "import shardflux; print(shardflux.__version__)".

  • Python 3.10 or later; one dependency (httpx), plus PyYAML with the yaml extra for template.yaml.
  • Typed (py.typed).
  • Retries, idempotency keys, operation waits and tool-token refresh are handled for you.
  • Questions or ideas? sf.send_feedback() (0.5.0+) reaches the Shardflux team directly; coding agents are asked to use it too (see "Feedback").

Test your integration (0.12.0+)

Use the same client calls in fast, isolated tests with shardflux.testing:

from shardflux.testing import create_fake_shardflux

with create_fake_shardflux(
    on_exec=lambda request, view: {"stdout": "ready\n", "exit_code": 0}
) as cloud:
    ref = cloud.workspace("acme/test", template="python-node-browser")
    result = ref.exec(["python3", "-V"], session_id="version-check")
    ref.files.write("/home/user/result.txt", result.stdout)

The fake returns real SDK workspace handles, refs and errors. Lifecycle calls, labels, paginated lists, files, command output, desktop actions and port lists use an isolated in-memory store. Command results come from on_exec; returning None leaves a command running for cloud.testing.complete_command(id, session_id, **result). Unscripted commands and unimplemented routes raise FakeUnsupportedError.

Control state with cloud.testing.set_faults(id, busy=True, failed=True, legacy_layout=True), computer_unavailable, deleted, resize_refusal (shrink_not_supported or resize_not_available), and memory_limit_mib (a plan clamp). Set cloud.testing.quota to "retained_state" or "concurrent_workspaces". cloud.testing.advance(milliseconds) drives idle deadlines; latency_ms and sleep inject request latency (the Python sleep callback receives seconds). cloud.testing.inspect(id) returns a copy of the raw workspace view. Each fake has its own state. The testing module uses the SDK's existing dependencies.

Testing extensions (0.12.1+)

The testing helpers also cover retention policies and projected deletion times, grow-only resize and stored caps, computer stream reuse and iframe options, turn cleanup, signal exit codes and typed missing-file errors. The virtual clock drives retention and link expiry. Tunnel handles model ownership and close cleanup with zero traffic; exercise forwarding and VM upgrades with the same application calls against the API.

Support and bug reports

Report a bug or request a feature. Include the package and runtime versions, a minimal reproduction, expected and actual behavior, and the request ID or error code when available. Reports are public: leave out secrets and confidential data.

For usage, account, billing, or private support, email shardflux@heliosone.fi. Report vulnerabilities through private security reporting. See the support guide for all reporting options.

Reverse tunnels (0.12.0+)

Forward connections verify the cached guest helper before starting their carrier (0.23.0+); concurrent cold connects share one upload. A reused workspace client verifies once per ownership epoch and helper version, so warm connects add no verification exec.

Reach a service on this machine from a workspace:

with ws.tunnels.reverse(remote_port=8081, target="localhost:8081") as tunnel:
    # Commands in the workspace can call http://127.0.0.1:8081.
    print(tunnel.stats)

The guest listener binds to loopback by default, 127.0.0.1 and ::1 (0.22.0+), so guest clients of localhost connect either way; bind_address="0.0.0.0" selects all guest interfaces. The target is dialed from this machine. TCP binary data, half-close and resets pass through: a reset on either side reaches the other as ConnectionResetError (0.16.0+), so a truncated stream never reads as complete. session_id identifies the forwarder; stats reports connections, bytes in each direction and reconnects; wait(timeout=None) waits for close or raises on failure. The context manager closes sockets and the guest process. An open tunnel keeps the workspace awake; persistent workspaces preserve it across explicit suspend/resume. The caller needs exec and files access.

The tunnel selects a streaming transport automatically when pty permission is granted. Choose transport="pty" or transport="exec" to select it explicitly; tunnel.transport reports the selection.

Bulk transfers batch writes automatically. Active transfers retain their byte position through network reconnection.

Install

pip install shardflux
pip install 'shardflux[yaml]'  # also reads template.yaml (templates.build_from_file, 0.3.0+)

Tool-call capture integrations have extras (0.3.0+): shardflux[claude-agent-sdk], shardflux[openai-agents], shardflux[langchain], shardflux[pydantic-ai], shardflux[crewai].

Quick start

Create a project API key in the Shardflux console (sfk_<key id>_<secret>). API keys are server credentials; keep them out of client-side code.

from shardflux import workspace  # reads SHARDFLUX_API_KEY

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

That is the whole integration (0.11.0+): the key names the workspace, the first call creates it, later calls (from any process) reuse it, and it suspends when idle and wakes on the next call with its files, packages and processes. See Workspaces by key.

Open, suspend and resume

The same workspace with every 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="python-node-browser")

# 1. Open the workspace (created on first use) and run a command. Check that it worked.
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))  # (0.2.0+) 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. The same program is in examples/quickstart.py (SHARDFLUX_API_KEY=sfk_... python examples/quickstart.py).

With 0.1.0, drop format_timing and last_timing; everything else above works as shown.

Workspaces by key (0.11.0+)

workspace(key, template=...) (or sf.workspace(...) on your own client) names a workspace by the identifier your application already has: a user, a thread, a repository, a customer. It makes no request. Its first call opens the key: the workspace is created from template on first use and resumed afterwards, in one request that also brings back its tool token. Later calls go straight to the workspace.

from shardflux import workspace

ws = workspace(f"acme/{thread_id}", template="default")

ws.exec("pip install requests && python3 app.py")  # a string runs through bash -lc
ws.exec(["python3", "-c", "print(42)"])  # a list runs without a shell
ws.files.write("/home/user/notes.txt", "hello\n")
print(ws.files.read_text("/home/user/notes.txt"))

ws.created  # True when this ref's open created the workspace
full = ws.open()  # the Workspace: suspend, fork, ports, computer, capture
  • The keyword arguments are open()'s: template is required ("default" is the platform default template, python-node-browser), plus secrets, inputs, caps, labels, idle_policy, lifetime, computer_use, agent_label, tools.
  • Calls from other threads while the first open runs wait for it and share it. A failed open is not kept: the next call opens again.
  • create=False never creates: the first call finds the key's workspace and raises 404 not_found when it has none.
  • A workspace deleted under the ref (is_workspace_gone(err), 409 workspace_deleted) fails the call that meets it; the next call opens the key again, which creates a new workspace.
  • ws.hint() starts the open early, for example when your model starts a tool call.

In an agent. The tools exist before the workspace does; the model's first tool call creates it:

from shardflux import execute_tool_call, to_anthropic_tools, workspace

tools = workspace(f"acme/{thread_id}", template="default").tools()  # reads the key's grants
anthropic_tools = to_anthropic_tools(tools)
# for each tool_use block the model returns:
output = execute_tool_call(tools, block)

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 waits its turn for admission (see "Retries and waits")
http_client a new httpx.Client (pass your own for proxies or custom transports)
on_progress (0.2.0+) 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.

The client it creates keeps an idle connection reusable for 5 minutes (0.9.0+), so the call after an agent's pause between tool calls skips the TCP and TLS handshake; direct connections send TCP keepalive probes after 60 s idle. Proxy settings from the environment apply. A request the SDK retries (safe methods, requests with an Idempotency-Key, uploads and searches) whose connection closes before the response arrives is sent again at once on a new connection (0.9.0+), without backoff and outside max_retries; timing records list it with delay_ms 0.

Retries and waits (0.18.0+)

Calls wait their turn. When a workspace's host is making room to wake it (503 host_capacity) or every working slot of your plan is in use (429 quota_exceeded, from a tool call or an open, resume or fork), the call is admitted a moment later: the SDK sends it again for you, any call, exec and terminal starts included. These answers come before the call reaches the workspace, so nothing runs twice.

  • Each wait is the answer's Retry-After plus up to 20 % jitter (exponential backoff with full jitter when there is none), for up to admission_timeout seconds in all (client option, default 120; sf.request(..., admission_timeout=...) for one call; 0 hands you the first answer).
  • on_progress sees each retry with reason, queue_position (your call's place in the host's queue), retry_after_seconds and waited (seconds so far); RetryRecord in last_timing.retries has the same fields.
  • Other retryable answers (rate_limited, dependency_unavailable, ...) wait their full Retry-After with the same jitter, within the request's timeout; a longer wait hands you the error with its retry_after.
from shardflux import ProgressEvent, Shardflux

def progress(e: ProgressEvent) -> None:
    if e.type == "retry" and e.queue_position is not None:
        print(f"{e.action}: position {e.queue_position}, next try in {e.retry_after_seconds} s")

sf = Shardflux(on_progress=progress, admission_timeout=300)

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

r.exit_code, r.stdout, r.stderr, r.timed_out, r.ok

A string runs through bash -lc; a list runs as argv. timeout (seconds) is enforced inside the workspace. Pass on_output=lambda stream, chunk: ... to receive output as it arrives. The start and the output are one request (0.9.0+). If the connection drops, exec resumes the output from byte offsets; it never starts the command twice. Ctrl-C cancels the command in the workspace.

cwd is an absolute path; commands start in /home/user when you leave it out. A relative cwd such as "app" is refused with ShardfluxApiError 422 validation_failed, reason invalid_cwd, and the message names the path it likely means (use "/home/user/app"). A command that could not start (a cwd that is not a directory, a program not on PATH, an unknown user) raises ExecStartError (0.6.0+), a ShardfluxApiError (409 conflict, reason exec_failed_to_start) with the workspace's reason in its message. Nothing ran, so there is no exit code. Before 0.6.0 exec returned exit_code=None with empty output.

from shardflux import ExecStartError

try:
    r = ws.exec("npm test", cwd="/home/user/app")
except ExecStartError as err:  # (0.6.0+) e.g. /home/user/app does not exist
    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")

Writes are atomic and durable: they are acknowledged after the file and its directory are fsynced.

Search, patch and revisions (0.5.0+)

hits = ws.files.search("/home/user/project", "TODO", include=["**/*.py"], context_lines=1)
for m in hits["matches"]:
    print(f"{m['path']}:{m['line']}:{m['column']}: {m['text']}")

# A file's revision is the SHA-256 of its content.
revision = ws.files.stat("/home/user/project/app.py", revision=True)["revision"]
patched = ws.files.patch(
    "/home/user/project/app.py",
    edits=[{"old_text": "DEBUG = True", "new_text": "DEBUG = False"}],  # must occur exactly once
    expected_revision=revision,  # refused if the file changed meanwhile
)
patched["revision"]  # the next expected_revision

info = ws.files.read_with_info("/home/user/project/app.py")  # FileRead(data, size, revision, served_from)
  • search() searches a directory (or one file) and returns {matches, truncated, stop_reason?, files_scanned, served_from}: matching lines in path order (1-based line and byte column), stopping at max_matches (default 200), a 10 s budget or 4 MiB of results (stop_reason). regex=True takes RE2 syntax. include/exclude globs are gitignore-style (*.py at any depth, src/**/*.ts relative to the path, build/ directories only). Binary files, symbolic links, files above max_file_bytes and .git/node_modules (unless exclude is given) are skipped. It is read-only, so transient failures are retried.
  • patch() applies all edits or none (replace_all for every occurrence), or replaces the whole file with content, atomically and durably, and always sends an Idempotency-Key. expected_revision="absent" requires that the file does not exist yet. A changed file raises ShardfluxApiError 409 with reason revision_mismatch and details["current_revision"]; an edit that does not match exactly once raises 422 edit_not_found or edit_ambiguous with details["index"]. A request is at most 7 MiB (413 payload_too_large; write larger files with write()), a file at most 64 MiB.
  • A suspended workspace is read, listed and searched from its saved disk without waking it (served_from == "disk": the state at suspension). This also works for a handle without a tool token from before the suspend: the API issues tokens for suspended workspaces. Everything else wakes it as usual.
  • If search or patches are not available for a workspace, the call raises ShardfluxApiError 409 conflict with reason host_feature_unavailable and details["feature"] (file_search, file_patch), not retryable: run grep with exec, or read then write the file, instead. Revisions are omitted there.

Wake hint (0.5.0+)

An idle running workspace is parked and restored by the next tool call. ws.hint() says a call is coming so the restore starts earlier: call it when your model starts emitting a tool call. It returns at once with WakeHint(residency, wake); a suspended workspace is resumed in a background thread (wake is a concurrent.futures.Future; nothing has to wait for it, ws.hint(wake=False) only reports). (0.6.0+) wake=None also only reports, and a callable wake(seconds) replaces the background wake. The agent tools of workspace_tools() send the hint themselves when a call starts (see "Agent tools" below).

Lifecycle

ws.suspend(wait=True)  # memory and processes are checkpointed; returns once suspended
ws.resume(wait=True)  # or simply 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

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

Requested or finished

suspend, resume, snapshot, delete, close and reset start a lifecycle operation and return it. What the call means depends on wait:

Call Returns when Returns
ws.suspend() the suspend is requested (usually queued; the workspace is still running) the Operation
ws.suspend(wait=True) the suspend has finished (ws.state is then suspended) the succeeded Operation
ws.suspend(durable=True) (0.8.0+) the suspend has finished and its copy is in durable storage (result["durable"] is True) the succeeded Operation

fork() works the other way round: it waits by default and returns the new workspace; pass wait=False to get it as soon as the fork is requested. The same calls on sf.workspaces (sf.workspaces.suspend(workspace_id, wait=True), ...) take wait too (0.2.0+).

A waited fork is one request (0.9.0+): the API holds the fork until the copy runs, and the answer carries the running copy and a tool token for it, so the copy is ready with no operation poll, view refresh or token request. agent_label and tools pick the token it brings back. Its timing is one request phase with reason held; server_wait=False polls instead. An API without the held fork answers at once, and the SDK then waits for the operation and refreshes the copy, as before.

A failed operation raises OperationFailedError. If timeout (default 300 s) passes first, OperationTimeoutError is raised and the operation keeps running server side: wait again with sf.workspaces.wait_for_operation(err.operation_id). Without wait, the returned operation is the handle for the work in progress: pass its id to wait_for_operation() when you need it finished.

Durable storage (0.8.0+). A suspend returns as soon as the workspace is sealed on its host, typically in a few hundred ms, and its RAM and CPU are released at that moment. result["durable"] turns True when the copy lands in durable storage, typically within a few seconds; until then it is False and result["durability"] shows the copy's progress. A suspended workspace resumes, is read and is forked the same way either way. When your code must know the copy is durable (before deleting a local artifact, or at the end of a job), ask for it:

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=..., local_commit_to_durable_ms=...)
  • durable=True implies wait and shares its timeout. If the time runs out first, OperationTimeoutError has durable set to True and the copy continues server side.
  • sf.workspaces.wait_for_durable(operation_or_id) does the same for an operation you already hold, such as a fork of a running workspace, whose result carries durable and durability the same way.
  • is_durable(op), durability_of(op) and ws.last_timing.server.durable / .durability read the fields; format_timing() prints sealed on host, durable 435 ms later. Results from servers before this release count as durable.
  • The errors reference on docs.shardflux.dev lists what durable=True can raise.

Starts queued for capacity. An open, resume, restore or fork that is waiting for room (capacity_pending) starts in arrival order while the platform adds capacity. Its phase progress events carry deadline_at (0.2.1+) and, (0.18.0+), queue_position, scale_out (requested while a new host starts), expected_ready_by and retry_after_seconds; a new event arrives each time the queue moves. Waits (open(), resume(wait=True), fork(), wait_for_operation(), wake()) follow the start until it runs, past their own timeout, up to its deadline_at, which moves later while a new host is starting (until_capacity_deadline=True, the default from 0.18.0; False keeps the timeout, and OperationTimeoutError then carries deadline_at and queue_position). A start still pending at its deadline fails with capacity_unavailable: nothing was started, the concurrency slot is released, and a suspended workspace stays suspended with its state. OperationFailedError.retryable (0.2.1+) is True for it, so you can tell a retryable failure from a definitive one.

from shardflux import OperationFailedError

try:
    ws.resume(wait=True)
except OperationFailedError as err:
    if not err.retryable:
        raise
    # err.error_code == "capacity_unavailable": nothing changed; retry.

Waiting asks the API to hold each poll until the operation changes (Prefer: wait, at most 20 s per request), so completion arrives within one round trip of the commit (0.2.0+). Against an API without bounded waits (or with server_wait=False) it polls with backoff (250 ms doubling to 5 s, ±20 % jitter). A waiting open() asks the API to hold the open itself until the workspace is ready; the answer carries the first tool token, so the first tool call starts at once (0.2.0+).

Suspend when idle (0.6.0+)

A running workspace is billed while it is awake, and its idle policy waits a while before suspending it. When your agent's turn ends, ask for a suspend once the workspace has been idle for a short time instead:

result = ws.suspend_when_idle(after_seconds=60)  # 0..3600; 0 = as soon as it is idle
ws.suspend_request  # SuspendRequest(requested_at, after_seconds, not_before) until it applies or is cancelled
ws.cancel_suspend_when_idle()  # idempotent
  • The idle time counts from the later of the workspace's last work and the request; not_before is the earliest suspend.
  • A command still running, an attached exec or terminal stream, or a keepalive postpones the suspend until after_seconds after it ends.
  • The next tool call on the workspace (the next turn) or a resume cancels the request. Repeating replaces it.
  • It applies under every idle policy, never included, and never delays a suspend the policy would do sooner.
  • When a suspend is already in progress, result.operation is that suspend and result.suspend_request is None.
  • Errors: ShardfluxApiError 409 with reason not_running, operation_in_progress, session_lifetime or workspace_deleted, and 422 validation_failed for after_seconds outside 0..3600. A file-first workspace is never suspended: NotSupportedForModeError (409 not_supported_for_mode).
  • By id: sf.workspaces.suspend_when_idle(workspace_id, after_seconds=60) and sf.workspaces.cancel_suspend_when_idle(workspace_id).

Suspended workspaces 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 to finish. 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, naming the operation, its state and reason. A failed resume or open raises OperationFailedError at once.
  • The wake is one request (0.5.0+): the API holds the resume until the workspace runs and answers with the view and a tool token for the calling client, so the refused call is retried at once (refused call, resume, the call). Its timing is one request phase with reason held. An API without the held resume answers at once; the SDK then waits for the operation, reads the view and fetches a token, as before.
  • Following an exec's output never wakes a workspace, so an explicit suspend() is respected.
  • ws.wake(timeout=...) does the same on demand (True when it resumed or waited, False when the workspace was already running; agent_label / tools pick the token it brings back). ws.resume(wait=True) is the same single held request: the handle keeps the view and the token.
  • ws.cell(wake=None) returns workspace_not_running instead.
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

Detecting a cold resume (0.7.0+)

A resume restores memory and running processes from the checkpoint. After a platform runtime update, or after the machine the workspace ran on failed (0.9.1+), a resume can boot from the saved disk instead of restoring memory (a cold boot), also when a tool call wakes the workspace; check memory_restored. The workspace runs with its files kept, and its processes start fresh, as after a reboot: start dev servers, databases and background jobs again. The resume's timing says which it was:

t = ws.last_timing  # after resume(wait=True), wake(), or a tool call that woke the workspace
if t and t.server and t.server.memory_restored is False:
    ...  # t.server.resume_path == "cold_boot", t.server.cold_boot_reason == "runtime_changed"
  • ServerTiming.memory_restored: True when memory and processes came back, False when the workspace booted (resume_path cold_boot, or reset_blank_layer after a reset), None when the API did not say (older APIs).
  • ServerTiming.cold_boot_reason: why it booted, runtime_changed or host_lost (0.9.1+), else None. format_timing() prints resume from cold_boot: processes restarted (runtime_changed).
  • The finished resume operation carries the same in result (memory_restored, cold_boot_reason, and cold_boot with details such as files_as_of; it is informational and not part of the stable contract).

Workspaces recover by themselves (0.9.1+)

When the machine a workspace runs on fails, the workspace is suspended, and its next use (a tool call, resume(), open()) restores it; there is nothing extra to call. host_lost_of(op) and last_timing.server.host_lost (HostLost) say how:

  • restored_from="disk": it booted from its own disk, files kept (cold_boot_reason host_lost, processes restarted).
  • restored_from="checkpoint": it resumed its newest checkpoint (restored_checkpoint_id); its state is as of state_as_of. format_timing() adds restored checkpoint <id> (host_lost; state as of <time>).
  • A suspend() that finds the machine failed succeeds (result["durable"] True, host_lost.detected_at).

Elastic memory (0.9.0+)

A workspace can hold only the memory its commands use and grow up to memory_mib when they need more; billing counts the memory it holds. An elastic workspace idles at its held floor (memory_mib_held, default 1024) and shrinks back after 30 s without work. Package installs, test runners, compilers, type checkers and bundlers get their memory before they start, so they size their heaps and workers from what they will actually get; everything else starts at once and the workspace grows while it runs.

ws = sf.workspaces.open(
    key="customer-42/repo-7",
    template="python-node-browser",
    caps={"memory_mib": 8192, "allocation_mode": "elastic"},  # a shardflux.Caps
)
ws.memory  # {"allocation_mode": "elastic", "promised_mib": 8192, "held_mib": 1024, "plugged_mib": 3072}
r = ws.exec(["npm", "test"])
r.memory_grow  # MemoryGrow(outcome="delivered", from_mib=1024, want_mib=4096, got_mib=4096, deliver_ms=175)

ws.exec(["python3", "train.py"], resource_hint="heavy")  # 0.10.0+: memory first, then the command

resource_hint (0.10.0+) is "auto" (the default: decided from the command), "heavy" or "light", on ws.exec() and cell().exec_run(); the exec agent tool takes it too. "allocation_mode": "fixed" gives a static allocation of memory_mib. Send allocation_mode with every caps you pass to keep the mode you chose; caps=None keeps the stored layout, and a fork without caps inherits its source's. The exec agent tool adds memory_grow to its result when the command's start grew the workspace.

Resize a workspace (0.10.0+)

Change the memory, CPU and disk of any workspace, running or suspended, fixed or elastic, without a restart:

r = ws.resize(memory_mib=6144, disk_gib=20)
r.memory  # {"applies_at": "now", "previous_mib": 2048, "applied_mib": 6144, "converged": True, ...}
r.disk  # {"applies_at": "now", "previous_gib": 10, "applied_gib": 20, ...}

ws.resize(allocation_mode="elastic", memory_mib_held=1024)  # switch modes live

Memory changes live, and disks grow online. A suspended workspace gets its new size when it resumes, before its first call. CPU changes live within the vCPUs the workspace booted with; a larger CPU size applies at the next start. Each resource says when it applies (applies_at: now, resume or next_start), and the new caps are stored, so every later start uses them. cloud.workspaces.resize(workspace_id, ...) does the same by id.

Burst execution (0.9.0+)

Run one heavy command, such as a cold build or a full test suite, on a larger VM without resizing the workspace. With burst="always" the command runs on a short-lived burst VM over a copy of the workspace, output streams as usual, and its file changes are applied back byte for byte when it exits.

r = ws.exec(["go", "build", "./..."], burst="always", burst_vcpus=16)
r.exit_code  # 0
# BurstSummary(host, applied, vcpus, memory_mib, method, written_files, written_dirs, removed, written_bytes,
# leftover_killed, overhead_ms, excluded_paths, timings, replayed, error); None for an ordinary exec
r.burst  # host="local", vcpus=16, memory_mib=8192, method="layer", applied=True, ...

A burst carries back the command's file changes; processes it started and tmpfs content stay in the burst VM, and the workspace's own processes resume where they were. If applying the changes fails (burst_apply_failed), run the exec again with the same session_id to finish the apply. The agent tools offer bursts when asked (burst=True).

burst_vcpus (1-32) and burst_memory_mib (512-65536) default to the host's size and may not exceed the plan's ceilings. Refusals are ShardfluxApiError: 422 validation_failed with reason burst_mode_not_supported, burst_not_supported (with stdin) or burst_size_exceeds_plan; 409 burst_unavailable (reason not_available, layout_unsupported, shared_volumes, host_capacity, ...; the workspace is unchanged) and 409 burst_apply_failed (reason disk_full, apply_failed, reverted or revert_failed, with details["applied_entries"] and details["pending_entries"]; a retryable apply_failed is finished by running again with the same session_id). A burst that fails after its start answered raises the same errors from exec(), never retried as a dropped stream. A burst cannot be signalled or canceled (409 conflict burst_not_supported); Ctrl-C stops following it, not the command. The agent tools offer it only on request: workspace_tools(ws, burst=True) gives the processful exec tool burst, burst_vcpus and burst_memory_mib inputs and adds burst (the summary) to its result for a burst; by default, and on a file-first workspace, the tool schemas are unchanged.

Timing and progress (0.2.0+)

Every open, wake and lifecycle call is traced. ws.last_timing (and err.timing when the call fails) says where the time went, and format_timing() prints it:

from shardflux import format_timing

ws = sf.open(key="customer-42/main", template="python-node-browser")
ws.suspend(wait=True)
ws.resume(wait=True)
print(format_timing(ws.last_timing))

The resume then reads, for example:

resume 413 ms, succeeded (workspace 01a0ead6-92e5-7d19-ab7a-f565bf4676cb, operation 01a0ead6-bd61-737a-ae88-66e2d01a6e25)
  client: request 218 ms → queued 195 ms
  server: queued 51 ms, ran 290 ms, total 341 ms; resume from local_cache, boot to ready 211 ms, host disk 0 ms, load 6 ms, ready 62 ms, total 211 ms, after_restore 36 ms
  outside the server: 72 ms

This resume brought the workspace back from its host's local cache, memory and running processes included, in 341 ms on the server and 413 ms end to end.

  • client phases are on your monotonic clock: the request (a held open waits on the server: held), then each operation state the SDK observed while waiting, with the server's reason (capacity_pending / no_ready_host: queued to start; running / template_downloading: fetching the template), then reading the workspace and issuing the first tool token (together: ∥).
  • server timing comes from the operation itself (one database clock): queued is creation until it began running, including any time in capacity_pending; ran is the cell's work (placement, boot or restore, guest readiness). start / resume from and boot to ready / host ... are what the cell reported. A resume that booted the saved disk instead of restoring memory reads resume from cold_boot: processes restarted (runtime_changed) (0.7.0+), or (host_lost) (0.9.1+) after the machine the workspace ran on failed.
  • outside the server is your total minus the operation's: network, TLS, polling latency, view and token. A large value with a small server total points at the connection between you and the API, not at the workspace.
  • retries lists transient failures the SDK retried (cause and backoff).

The timing is a LifecycleTiming dataclass (phases, retries, server, outside_server_ms, ...). Watch it live with on_progress, on the client for every call or per call (sf.open(..., on_progress=...), ws.suspend(wait=True, on_progress=...), sf.workspaces.wait_for_operation(..., on_progress=...), ws.wake(on_progress=...), ws.cell(on_progress=...)):

import sys

from shardflux import ProgressEvent, Shardflux, format_timing

def progress(e: ProgressEvent) -> None:
    if e.type == "phase":
        print(f"{e.action}: {e.phase}{f' ({e.reason})' if e.reason else ''} 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)

Events are phase (a phase began), retry and done (with the timing). Tool calls add token traces (a tool token fetched: initial, expiring or invalidated) and tool events (a busy wait, a stale token replaced, a retry). A listener that raises never breaks the call. queued and ran need an API that reports the operation's started_at (Operation.started_at); otherwise they are missing.

Sessions, reset and changes (0.2.0+)

# A session workspace is discarded when the session ends: close(), or the idle timeout
# (idle_timeout_seconds, default 600). The same key then opens a new, empty workspace.
with sf.open(key="agent-7/task", template="python-node-browser", lifetime="session") as ws:
    ws.exec("python3 run.py")
# Leaving the block called ws.close(). For a persistent workspace close() makes no API call: it only
# drops cached tool tokens, so it is safe in `finally`.

sf.workspaces.list(lifetime="any", purpose="any", include_deleted=True)  # default: persistent standard only
ws = sf.workspaces.get_by_key("customer-42/main")  # any lifetime/purpose; live row over tombstones; None if absent

ws.reset(wait=True)  # layered workspaces: wipe every change, back to the template
page = ws.changes(path_prefix="/home/user", hash=True, summary=True)  # what changed vs. the template
for c in page.data:
    print(c.change, c.path)  # added, modified, metadata, deleted, replaced

Workspace views also carry disk_layout, purpose, origin, ended_reason and dev_template_id; the handle's immutable_version (0.9.0+) is the template version whose immutable paths the workspace has mounted. Reset, changes() and save-as-template need a layered workspace.

File-first workspaces (0.5.0+)

A file-first workspace has no VM between commands. Its state is a versioned file tree under /home/user: revision 0 is the empty tree, and every change publishes the next revision. File calls work on the latest revision without a VM. Each command runs as an execution: a fresh VM on the latest tree runs the command, and the files it changed become the next revision. Nothing else survives an execution: processes, memory, and files outside /home/user are gone. The workspace is ready as soon as it is opened and is never suspended. An account without file-first workspaces gets 422 mode_not_available from open().

from shardflux import TreeRevisionMismatchError

# There is no separate create call: the first open creates the workspace. The mode is fixed at creation.
ws = sf.open(key="customer-42/build", template="python-node-browser", mode="file_first")
ws.mode, ws.tree_revision  # ("file_first", 0)

ws.files.write("/home/user/app.py", "print('hello')\n")  # publishes revision 1

run = ws.executions.run("python3 app.py > out.txt && echo done", timeout=60)
run.ok, run.exit_code, run.text()  # (True, 0, "done\n"): stdout and stderr are bytes; text() decodes them
for change in run.changed:  # [ExecutionChange(path="/home/user/out.txt", change="added", type="file")]
    print(change.change, change.type, change.path)
run.tree_revision, ws.tree_revision  # (2, 2)

# Make a change conditional on the tree not having moved since you last looked.
try:
    ws.files.write("/home/user/app.py", "print('v2')\n", if_tree_revision=ws.tree_revision)
except TreeRevisionMismatchError as err:
    print("the tree moved to", err.current_tree_revision)  # read what changed, then retry

# Read the result of an execution again, or wait for one started elsewhere.
again = ws.executions.get(run.execution_id)  # replayed=True; a running one has pending=True
done = ws.executions.get("nightly-build-2026-09-28", wait=120)  # polls until it ended or 120 s passed
  • executions.run(cmd, execution_id=None, cwd=, env=, stdin=, timeout=, user=, secret_refs=, output_limit_bytes=, max_retries=5, attempt_timeout=300) returns an ExecutionResult once the command ended. A string runs through bash -lc; a list runs as argv. execution_id is the idempotency key. When you leave it out, a fresh ex-<uuid> is used. If you set your own, it must be 8-128 characters of A-Z a-z 0-9 . _ : -, else ValueError. Sending the same id with the same request returns the recorded result (replayed=True) and never runs the command again. Sending it with a different request raises 409 execution_id_reused.
  • Every retry reuses the same id and the same body. Network failures and retryable 429/502/503/504 refusals, such as 503 no_execution_host (the execution cannot be placed right now), are retried up to max_retries times. The SDK waits Retry-After (capped at 30 s) between them. attempt_timeout bounds each wait for the answer. When it passes, the request is sent again with the same id and joins the running execution; this is not counted as a failure. When another execution holds the workspace (409 workspace_busy, reason execution_in_progress), the call waits like any busy tool call. The SDK never switches to a new id on its own.
  • state is succeeded, failed or lost. A command that exits non-zero or times out still succeeded: check exit_code and timed_out, or ok. A failed or lost execution is returned, not raised, and publishes nothing: error_reason says why (lease_expired, exec_failed_to_start, ...), and error["retryable"] says whether a new id may succeed. tree_revision is base_revision + 1 when files changed, base_revision when nothing did, and None unless the execution succeeded. changed lists up to 10,000 entries (changed_truncated). stdout and stderr are capped at output_limit_bytes each (1 MiB by default; see stdout_truncated). timings holds milliseconds.
  • ws.tree_revision is the newest revision the handle has seen. It comes from the view, X-Tree-Revision on every cell response (including refusals), execution results and mismatch errors, and it only moves forward. files.write, files.remove and files.patch take if_tree_revision, which is sent as If-Match.
  • Some calls don't exist in file-first mode: exec() (use executions.run()), changes(), suspend(), resume(), snapshot(), fork(), reset() and save_as_template(). They raise NotSupportedForModeError (409 conflict, reason not_supported_for_mode, local=True) without a request. wake() returns False and hint() returns resident. On a processful workspace, executions and if_tree_revision raise the same error. When the view has no mode (an older API), the calls are sent and the server's refusal is raised as the same typed error.
  • (0.6.0+) workspace_tools(ws) of a file-first workspace offers exec and the files tools only; its exec runs each command as an execution (see "Agent tools" below).

Templates (0.2.0+)

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

result = ws.save_as_template("my-agent", description="Agent with tools preinstalled")
result.build_id, result.build["target_version"]

draft = sf.templates.draft("my-agent")  # dev mode: edit a template live
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:  # disposable copy of that state
    test.exec("python3 -m pytest")
draft.publish(state_id=state, description="deps")  # the next version
draft.discard()

Drafts and test instances are for owners, admins and API keys with a tool permission. A draft is a persistent workspace (purpose="template_draft"); test instances are sessions.

Build a template from template.yaml (0.3.0+)

A template can be built from a recipe: a base, languages, packages, files, build steps and the settings every workspace of it gets (environment, open-time inputs, start commands, services, defaults). template.yaml is that recipe (recipe v2) in YAML. Reading YAML needs PyYAML:

pip install 'shardflux[yaml]'

(A template.json, or a template.yaml written as JSON, needs nothing; parse_yaml= accepts any parser.)

# template.yaml (schema: shardflux.template-recipe.v2 is added when absent)
base: python-node-browser@7        # services need a base whose agent runs them (python-node-browser 7+)
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
  steps:
    - name: warm-cache
      run: python -c 'import app' || true
      user: user
      cwd: /home/user/app
settings:
  env:
    APP_ENV: development
  inputs:
    PROJECT_NAME: {kind: text, default: demo, description: Shown in the title.}
    OPENAI_API_KEY: {kind: secret, required: false}
  start:
    - name: seed
      when: create
      run: python seed.py --project "$PROJECT_NAME"
      cwd: /home/user/app
  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)
result.build["state"], result.build["registration"]["state"]  # "published", "registered" once it is usable
result.build["provenance"]["recipe_sha256"]  # the same inputs are the same template
for u in result.uploads:  # one per local source: from, path, to, kind, sha256, size, uploaded, entries
    print(u["from"], u["sha256"], "uploaded" if u["uploaded"] else "already there")
  • Each from is packed (a folder: a reproducible, uncompressed tar, the same bytes on every machine), hashed and uploaded unless the organization already has those bytes; the recipe is sent with upload: "sha256:<hex>" instead. kind defaults to tar for a folder and file for a file; kind: tar on a file uploads a prepared uncompressed .tar as it is.
  • 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"], e.g. language_unavailable, platform_owned_path, invalid_settings).
  • Without wait=True the call returns the queued build; on_progress receives pack, upload and build events. Temporary archives are removed either way.

The pieces are available on their own:

from shardflux import pack_directory

packed = pack_directory("app", "app.tar")  # PackResult(sha256, size, entries)
up = sf.templates.uploads.put("app.tar", "tar")  # also bytes or a binary file object
up.ref, up.uploaded  # "sha256:<hex>", False when the organization had the bytes

build = sf.templates.builds.create(
    "my-agent",
    {
        "schema": "shardflux.template-recipe.v2",
        "base": "python-node-browser@7",
        "build": {"files": [{"upload": up.ref, "kind": "tar", "to": "/home/user/app", "owner": "user"}]},
        "settings": {},
    },
    auto_publish=False,
)
build = sf.templates.builds.wait(build["id"], on_change=lambda b: print(b["state"]))
sf.templates.builds.list(template="my-agent").data
sf.templates.builds.log_url(build["id"])["url"]  # presigned; a bearer credential, do not log it
sf.templates.builds.cancel(build["id"])

Builds belong to the API key's organization (read once from GET /v1/me; pass organization_id= to choose). A refused upload raises TemplateUploadError (status, S3 code such as BadDigest); a wait that runs out raises TemplateBuildTimeoutError with the last build (it keeps going server side).

Export, test, inputs and startup

exported = sf.templates.versions.recipe("my-agent", 3)  # "Edit template": the recipe in request form
sf.templates.builds.create("my-agent", exported["recipe"])  # same base and uploads: same recipe_sha256
exported["settings"]  # env, inputs, start, services, defaults as the version has them

detail = sf.templates.get("my-agent")  # versions with their settings; category "os"/"stack"/None
sf.templates.list(owner="platform").data

# A throwaway session on any version (published or not), with its inputs:
with sf.templates.version_test_instances.create("my-agent", 4, inputs={"PROJECT_NAME": "try"}) as ws:
    ws.exec("curl -s localhost:8000")

# Open with the template's text inputs; the version's start commands and services run at start.
ws = sf.open(key="customer-42/main", template="my-agent", inputs={"PROJECT_NAME": "acme"})
ws.inputs()  # {"PROJECT_NAME": "acme"}; secret inputs bind the stored secret of the same name
ws.startup  # {"state": "ready", "trigger": "create", ...}; None without start commands or services

A failed start command or service leaves the workspace running with startup["state"] == "failed" (step, service, exit_code, output_tail, reason); 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.

draft.create(display_name=..., inputs=...), draft.open_test_instance(inputs=...), draft.publish(settings=...) and ws.save_as_template(..., settings=...) take the same settings (each field given replaces the source version's; settings["defaults"] together with defaults is 422 invalid_settings).

Immutable paths (0.9.0+)

Directories a template lists under immutable follow the template: every workspace of it mounts them read-only from the template's newest published version, so the tools, models or data you ship there reach existing workspaces with your next version, at their next cold boot or resume. Everything else in a workspace stays its own.

# template.yaml
immutable: [/opt/acme]        # read-only in every workspace; follows the newest version
detail = sf.templates.get("my-agent")
detail["open_version"]["immutable"]  # {"paths": ["/opt/acme"], "bytes": 100663296}; None when it declares none
ws = sf.open(key="customer-42/main", template="my-agent")
ws.data["template"]["version"], ws.immutable_version  # created from v1; /opt/acme shows v2

A recipe without immutable keeps the list of the template's open version, and each new version keeps every path of it: add paths, never remove them. A build reports the list its version declares in build["immutable_paths"].

Languages and packages

langs = sf.templates.languages("python-node-browser@7")
[(l["id"], l["version"], l["included"]) for l in langs["data"]]  # included: the base has it already
sf.templates.packages.search("npm", "typescript")["data"]  # name, version, summary
sf.templates.packages.search("apt", "ffmpeg", base="ubuntu-24.04@1")  # apt searches the base's index
sf.templates.packages.get("pip", "pandas")["versions"]

Inbound ports (0.10.0+)

Serve a port of a workspace at its own HTTPS URL: the app your agent is building, a preview for your user, an API, or a webhook receiver. Every port is private. A request to a suspended or idle workspace wakes it and is served as soon as it runs, so a workspace can sleep between requests.

import httpx

# A server in the workspace listens on 0.0.0.0:3000 (a template service, or any process you start).
port = ws.ports.expose(3000)  # ExposedPort(port, url, created_at, callback, created)
port.url  # "https://3000-<handle>.shardflux.app"

# From your code: a port token in the Authorization header.
token = ws.ports.token(3000)  # PortToken(token, expires_at, url); ttl_seconds 60..86400, default 3600
httpx.get(f"{port.url}/health", headers={"Authorization": f"Bearer {token.token}"})

# For a person: a signed link that opens the port in a browser.
link = ws.ports.link(3000, path="/dashboard")  # PortLink(url, expires_at); ttl_seconds 60..604800, default 86400

# For webhooks and OAuth redirects: a callback URL that needs no token.
callback = ws.ports.create_callback_url(3000)  # PortCallbackUrl(url, created_at)
github_webhook_url = callback.url + "github/events"  # reaches /github/events in the workspace
  • The server must listen on 0.0.0.0 (all interfaces), not only on 127.0.0.1.
  • expose() is idempotent: created is True when the call exposed the port and False when it already was (its tokens, links and callback URL stay valid). ws.ports.list() returns the exposed ports, ordered by port.
  • Send the token as Authorization: Bearer <token>, or as X-Shardflux-Token: <token> when your app reads Authorization itself (another Authorization value reaches your app unchanged).
  • A link starts a browser session for the port that lasts until expires_at and lands on path (default /). Share it like a password.
  • A request to the callback URL plus a path reaches that path in the workspace (query kept). Register it with GitHub, Slack, Stripe or an OAuth provider. The URL is shown once; create_callback_url() again replaces it, and ws.ports.revoke_callback_url(3000) removes it.
  • Requests count as activity: the workspace's idle timeout runs from the last request.
  • ws.ports.close(3000) closes the port and revokes its tokens, links and callback URL at once (idempotent).
  • By id, without reading the workspace: sf.workspaces.ports(workspace_id), with the same methods.
  • Errors: ShardfluxApiError 404 not_found with reason port_not_exposed (a token, link or callback URL for a port that is not exposed: expose it first), 409 conflict port_limit (details["limit"]: close a port first), 403 forbidden inbound_ports_not_available (inbound ports are not enabled for the organization) and 422 validation_failed for a port outside 1..65535. Ports serve processful workspaces; a file-first workspace raises NotSupportedForModeError.

Computer use (0.11.0+)

A desktop in the workspace for agents that operate GUI software: a screen, a mouse and a keyboard. Switch it on for a workspace (or for every workspace of your template); the platform starts the desktop on the first call that needs it.

ws = sf.open(key="agent/42", template="python-node-browser", computer_use=True)

shot = ws.computer.screenshot()  # ComputerScreenshot(format="png", width=1280, height=800, data=...)
ws.computer.act(
    [
        {"action": "left_click", "coordinate": [640, 400]},
        {"action": "type", "text": "hello"},
        {"action": "key", "text": "Return"},
    ],
    screenshot=True,  # one round trip for the whole batch and a look
)
link = ws.computer.stream()  # a private link to watch the screen live

With Claude, answer the computer toolset directly:

from shardflux import computer_toolset

toolset = computer_toolset(ws)
msg = anthropic.messages.create(
    model="claude-opus-5-5", max_tokens=16000, tools=[toolset.definition], messages=messages
)
messages.append({"role": "assistant", "content": msg.content})
messages.append({"role": "user", "content": toolset.run(msg.content)})
  • The actions are Claude's computer toolset members with their parameters: screenshot, zoom, left_click, right_click, middle_click, double_click, triple_click, left_click_drag, mouse_move, left_mouse_down, left_mouse_up, cursor_position, scroll, type, key, hold_key, wait. A batch runs in order and stops at the first failure; later actions come back skipped.
  • toolset.run(content) sends every computer call of a model turn as one batch and returns one tool_result per call (each with toolset_name: "computer"), with a screenshot on the last one when the turn did not end with a look. It takes the Anthropic SDK's blocks or dicts.
  • workspace_tools() includes, for any model while the workspace's computer use is on, a computer tool (one action per call, answered with a screenshot) and computer_batch (several actions in one call, with each action's result and one screenshot after them). Both take screenshot: false to skip the image, settle_ms, and format: "jpeg" with quality for smaller images.
  • act(actions, screenshot=, settle_ms=, format=, quality=) takes the same options.
  • Commands the agent runs see the desktop: exec of chromium https://example.com & opens the browser on it.
  • stream(interactive=False, ttl_seconds=None) returns a signed link (view only by default; interactive=True lets the viewer use the mouse and keyboard); stop_stream() ends every view. Streaming uses inbound ports.
  • ws.set_computer_use(True | False | None) sets the workspace's own switch (None follows the template); ws.computer_use reads {enabled, workspace, template, available}. status(), start(width=, height=) and stop() manage the desktop directly.

Secrets (0.2.0+)

Store credentials once and give them to a workspace's processes as environment variables. Values are write-only: no API returns them. Every exec and terminal in the workspace receives the secrets bound to it, plus any the call names in 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"])

# Bind by name when opening (a new key gets the binding; an existing key has it replaced).
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
ws.exec("python3 agent.py")  # sees $OPENAI_API_KEY and $DATABASE_URL
  • A name that is unknown, or a secret this workspace may not use, raises ShardfluxApiError 422 (err.reason == "secret_not_available", details["names"]); nothing changes. A bound secret must allow the exec and pty tools (the default).
  • status per bound name: available, not_allowed (its permissions no longer cover this workspace; starts are refused with 403 until fixed) or deleted.
  • Deleting a secret removes it from every binding. Forks keep the binding, but secrets limited to specific workspaces are checked against the fork's own id.
  • sf.secrets also has list, get, update, rotate, versions, delete, access_events, create_organization and list_organization. Permission arguments you do not pass are left alone; None means "no restriction". Organization-wide secrets and access logs belong to organization owners and admins, so a project API key gets 403 for those.

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 tools: each has a name, a description, a JSON Schema for its parameters and execute, which validates the arguments against that schema and runs the call in the workspace. They are the same tools, names and schemas as workspaceTools in the TypeScript SDK. (0.11.0+) ws.tools() on a WorkspaceRef builds them from the API key's grants before the workspace exists (see Workspaces by key).

from shardflux import execute_tool_call, to_anthropic_tools, to_openai_tools, workspace_tools

tools = workspace_tools(ws)

anthropic_tools = to_anthropic_tools(tools)  # Anthropic Messages API
chat_tools = to_openai_tools(tools)  # OpenAI Chat Completions
responses_tools = to_openai_tools(tools, api="responses")  # OpenAI Responses API

# For each tool call the model makes: an Anthropic tool_use block, an OpenAI tool call or
# function_call item, or a dict with name and input/arguments. Returns a JSON-serializable dict.
output = execute_tool_call(tools, call)

The workspace keeps its files, installed packages and processes between calls and between conversations. If it is suspended, the next tool call resumes it. (0.6.0+) Each call first sends ws.hint() in the background, without waiting for it, so a parked workspace is being restored while the call is prepared (hint=False turns that off, e.g. when you send the hint yourself as the model starts a tool call). read_file, list_files and search_files send none: a sleeping workspace answers them from its disk without waking.

A complete loop with the Anthropic SDK (pip install anthropic, ANTHROPIC_API_KEY set):

import json

import anthropic
from shardflux import Shardflux, execute_tool_call, to_anthropic_tools, workspace_tools

sf = Shardflux()
client = anthropic.Anthropic()

ws = sf.open(key="agent-demo/main", template="python-node-browser")
tools = workspace_tools(ws, tools=["exec", "files"])

messages = [
    {"role": "user", "content": "Write /home/user/fizzbuzz.py, run it for 1 to 15, and tell me what it printed."}
]
while True:
    response = client.messages.create(
        model="claude-opus-5", max_tokens=16000, tools=to_anthropic_tools(tools), messages=messages
    )
    messages.append({"role": "assistant", "content": response.content})
    if response.stop_reason != "tool_use":
        print("".join(block.text for block in response.content if block.type == "text"))
        break
    # Run every tool call of this turn, and send all results back in one message.
    results = []
    for block in response.content:
        if block.type != "tool_use":
            continue
        try:
            output = execute_tool_call(tools, block)
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(output)})
        except Exception as err:  # bad arguments or a refused call: tell the model, so it can correct itself
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(err), "is_error": True})
    messages.append({"role": "user", "content": results})

The same program is in examples/agent_tools.py. With the OpenAI Responses API, pass to_openai_tools(tools, api="responses") as tools and each function_call item of response.output to execute_tool_call as it is; send the result back as {"type": "function_call_output", "call_id": item.call_id, "output": json.dumps(output)}.

Any other framework that takes a name, a description, a JSON Schema and a function can use the tools:

import json

for tool in workspace_tools(ws):
    print(tool.name, tool.permission, json.dumps(tool.parameters))

exec_tool = next(t for t in workspace_tools(ws) if t.name == "exec")
exec_tool.execute({"command": "python3 --version"})

execute and execute_tool_call raise ToolArgumentError (a ValueError, with issues) for an unknown tool, arguments that are not JSON or do not match the schema, before anything is sent. Errors from the workspace (ShardfluxApiError, e.g. not_found for a missing file) propagate. Send either back to the model as an error result. (0.6.0+) A command that could not start is not raised by exec: its result has exit_code None, empty output and error (code conflict, message, reason exec_failed_to_start), as a failed execution does. The tools are synchronous; in async code, run them with asyncio.to_thread.

The tools

Which tools you get depends on the API key's tool permissions and the tools option.

Tool Permission Parameters Returns
exec exec command (run with bash -lc), cwd (absolute), timeout_ms (1,000 to 86,400,000; default 600,000; with background, none unless set (0.9.0+)), stdin, background (0.9.0+) exit_code, term_signal, timed_out, stdout, stderr, truncated, session_id; error (code, message, reason) when the command could not start (0.6.0+). With background: session_id, state (and error)
exec_read (0.9.0+) exec session_id, stdout_offset, stderr_offset, wait_ms (up to 60,000) session_id, state, exit_code, term_signal, timed_out, canceled, stdout, stderr, stdout_offset, stderr_offset, next_stdout_offset, next_stderr_offset, truncated; error, burst when the session has them
exec_cancel (0.9.0+) exec session_id, grace_ms (1 to 60,000; default 5,000) session_id, state, exit_code, canceled
read_file files path, offset, length path, content (UTF-8), truncated
write_file files path, content, append, create_parents (default true) path, bytes_written, sha256, durable
list_files files path, limit (up to 10,000; default 500) entries (name, path, type, size, modified_at), truncated
search_files (0.6.0+) files path (a directory or one file), pattern (literal, or RE2 with regex), regex, case_insensitive, include / exclude (globs; exclude defaults to .git and node_modules), max_matches (up to 5,000; default 200), context_lines (up to 5) matches (path, line, column, text, before, after), truncated, stop_reason, omitted_matches, files_scanned
edit_file (0.6.0+) files path, edits (1 to 100 of old_text, new_text, replace_all), expected_revision path, revision, previous_revision, replacements, bytes_written
list_processes process none processes (pid, ppid, comm, cmdline, state, rss_bytes)
signal_process process pid, signal (such as SIGTERM) signalled
terminal_open pty command (default: a login shell), rows, cols session_id, state, next_offset
terminal_send pty session_id, input (include \n to press Enter) state, next_offset
terminal_read pty session_id, offset, wait_ms (up to 60,000) output, next_offset, exited, exit_code, truncated
terminal_close pty session_id state
git_clone git url (HTTPS), path, branch, depth, filter (0.13.0+) exit_code, stdout, stderr
git_status git path The repository's status
git_commit git path, message (stages all changes) exit_code, commit, stdout, stderr
computer (0.11.0+) computer action (a computer toolset member), coordinate, start_coordinate, region, text, scroll_direction, scroll_amount, duration, repeat; screenshot, settle_ms, format, quality ok, output, error, cursor, display, screenshot (mime_type, width, height, data_base64)
computer_batch (0.11.0+) computer actions (1-50 objects with the computer tool's action parameters); screenshot, settle_ms, format, quality ok, results (action, ok, skipped, output, error, took_ms, image), cursor, display, screenshot
browser_screenshot browser url, width, height mime_type (image/png), bytes, data_base64
browser_content browser url, format (text or html) url, format, content, truncated

Command output (stdout and stderr each), file content, terminal output and page text returned to the model are cut at 64 KiB per call (truncated: true), never inside a UTF-8 character; max_output_bytes changes that. terminal_read returns the next_offset to read from next: it counts exactly the bytes returned, so output past the limit comes with the next read. search_files returns whole matches up to that budget and counts the rest in omitted_matches.

Commands in the background (0.9.0+). A build, a test suite, a training run or a server runs with exec and background: true: the call returns the command's session_id at once, and the agent keeps working with the other tools while it runs. exec_read returns its state, exit code and the end of each stream; passing next_stdout_offset and next_stderr_offset back returns only the output since, and wait_ms waits for the command to exit and returns as soon as it does. exec_cancel stops it (SIGTERM, then SIGKILL after grace_ms). The workspace stays awake until the command ends or its timeout_ms passes (an hour when unset). A burst (burst: "always") runs in the foreground, since it holds the workspace until it applies its changes.

started = execute_tool_call(tools, {"name": "exec", "input": {"command": "npm run build", "background": True}})
# ... other tool calls ...
progress = execute_tool_call(tools, {"name": "exec_read", "input": {"session_id": started["session_id"]}})
done = execute_tool_call(
    tools,
    {
        "name": "exec_read",
        "input": {
            "session_id": started["session_id"],
            "stdout_offset": progress["next_stdout_offset"],
            "stderr_offset": progress["next_stderr_offset"],
            "wait_ms": 60_000,
        },
    },
)
print(done["state"], done["exit_code"], done["stdout"])

edit_file replaces exact text: each old_text must occur exactly once unless replace_all, and the edits apply in order, all or none. When the model passes no expected_revision, the tool reads the file's revision first and pins the patch to it, so a change made in between fails the edit (409 revision_mismatch) instead of being overwritten.

File-first workspaces (0.6.0+). For a file-first workspace (ws.mode == "file_first") the tools are exec and the files tools only: nothing runs between executions, so the process, terminal, git, browser and background-command tools are not offered. exec (timeout_ms up to 3,600,000) runs each command as an execution (a fresh VM on the workspace's files) and adds execution_id, state, tree_revision, changed (up to 200 {path, change, type}; changed_truncated) and, for a failed or lost execution, error to its result; it has no session_id.

Options

tools = workspace_tools(
    ws,
    tools=["exec", "files", "git"],  # default: the tools of the workspace's last token (ws.granted_tools), else all six
    agent_label="coder",  # attribution: one agent session per label
    prefix="workspace_",  # tool names become workspace_exec, workspace_read_file, ...
    max_output_bytes=65_536,  # bytes of output or file content returned to the model (default 64 KiB)
    default_cwd="/home/user/project",  # exec's working directory when the model gives none (default: the user's home)
    transition_timeout=120,  # longest wait per call for a suspended workspace to wake (seconds)
    hint=True,  # (0.6.0+) send ws.hint() in the background when a call starts (not for the reads)
    mode=None,  # (0.6.0+) "processful" or "file_first": build the tools without reading ws.mode
    on_execution=None,  # (0.6.0+) file-first: called with each execution id before it is sent
)

Pass wake=None to make calls on a suspended workspace fail with workspace_not_running instead of resuming it (the hint then only reports). With mode and tools given, the workspace is not read while the tools are built. An execution runs to completion: with on_execution, a caller that stops waiting can fetch the result later with ws.executions.get(execution_id). To save these calls with tool-call capture, pass the tools through capture.tools(tools) (see below).

Terminal, processes, git and browser without a model

The tools call methods of the workspace's cell client, which you can call yourself:

cell = ws.cell()
session = cell.pty_open(argv=["bash", "-l"])  # a terminal; pty_input / pty_resize / pty_close
cell.pty_input(session["session_id"], "ls\n")
read = cell.pty_read(session["session_id"], offset=0)  # {output, next_offset, exited, session, truncated}
cell.processes_list()  # {"data": [...]}; processes_signal(pid, "SIGTERM")
cell.git_clone("https://github.com/octocat/Hello-World.git", "/home/user/hw", depth=1)
# 0.13.0+: full history, with historical blobs fetched lazily from origin.
cell.git_clone("https://github.com/python/cpython.git", "/home/user/cpython", filter="blob:none")
cell.git_status("/home/user/hw")  # git_commit(path, message)
png = cell.browser_screenshot("https://example.com")  # bytes; browser_content(url, format="text")

pty_read reads the terminal's attach WebSocket over the SDK's own httpx connection (no extra dependency); pty_attach gives you the WebSocket itself (receive from one thread; send_json / close from any).

Tool-call capture (0.3.0+)

Your harness runs the model loop and your own tools (search, SQL, HTTP APIs, MCP servers). Their results reach the model's context but not the workspace. 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. They are also kept in snapshots and forks.

capture = ws.capture_tool_calls()  # one run directory under /home/user/tool-calls

Capture is invisible to the harness:

  • A wrapped tool returns the same object and raises the same exception. A sync tool stays sync, and an async tool stays async.
  • Writes happen on background threads. A call costs one snapshot of its data on the calling thread, well under a millisecond for typical results.
  • Nothing raised by capture reaches your code after capture_tool_calls(). Write failures, overflow and serialization problems go to on_error (a CaptureError) and to capture.stats.
  • The model's view is never changed.

Pick the pattern that matches your harness.

Shardflux agent tools (0.4.0+). capture.tools(workspace_tools(ws)) returns copies of the tools that record every call with the model's call id, which execute_tool_call passes on:

tools = capture.tools(workspace_tools(ws))
output = execute_tool_call(tools, block)  # recorded with call_id=block.id

Hand-rolled loop (Anthropic, OpenAI). Record each call with the model's id:

for block in response.content:
    if block.type == "tool_use":
        with capture.call(block.name, block.input, call_id=block.id) as call:
            call.output = my_tools[block.name](**block.input)
        # or afterwards: capture.record(block.name, block.input, output, call_id=block.id)

Decorated tools (anthropic @beta_tool/@beta_async_tool, openai-agents @function_tool, pydantic-ai @agent.tool/@agent.tool_plain, LangChain @tool, CrewAI @tool, llama-index FunctionTool.from_defaults). Stack @capture_tool directly under the framework's decorator. The framework still sees the same signature, docstring, type hints (including from __future__ import annotations) and async-ness, so the model gets the same schema.

from shardflux import capture_tool

@function_tool
@capture_tool  # records to the capture active in this context; passes through when there is none
def search_orders(ctx: RunContextWrapper, customer: str) -> list[dict]: ...

with capture.activate():  # per request/conversation, e.g. in a multi-tenant server
    await Runner.run(agent, "...")
  • @capture.tool (or capture.wrap(fn)) binds a tool to one capture instead.
  • The input is the call's arguments by name. Injected framework parameters are left out: RunContext, ToolContext, RunnableConfig, ToolRuntime, callback managers, and a tool_call_id parameter. The call id is read from them. exclude_args=("password",) leaves out more.
  • smolagents warns about extra decorators and rejects async tools, so use the call form: tool(capture_tool(fn)).
  • Generators are passed through item by item and stored as .jsonl. A generator closed early is recorded as incomplete.

Claude Agent SDK (pip install 'shardflux[claude-agent-sdk]'):

from shardflux.integrations.claude_agent_sdk import capture_hooks

options = ClaudeAgentOptions(hooks=capture_hooks(capture, merge=my_hooks))
  • PostToolUse and PostToolUseFailure record every tool, keyed by tool_use_id.
  • A PreToolUse hook matching ^mcp__shardflux__ waits for pending writes before a Shardflux MCP tool runs, so that tool sees them. Other tools are not slowed.

OpenAI Agents SDK (shardflux[openai-agents]):

from shardflux.integrations.openai_agents import CaptureRunHooks

result = await Runner.run(agent, "...", hooks=CaptureRunHooks(capture, inner=my_hooks))
  • Function tools are recorded with the call_id and raw arguments from ToolContext.
  • Hosted tools (MCP, code interpreter, file search, web search, image generation) are recorded from on_llm_end.
  • Hooks see a failing tool only as the SDK's error string, which is recorded with status error. Add @capture_tool under @function_tool to record the exception itself. The shared call id keeps it to one line.

LangChain / LangGraph (shardflux[langchain]):

from shardflux.integrations.langchain import CaptureCallbackHandler

agent.invoke({"messages": [...]}, config={"callbacks": [CaptureCallbackHandler(capture)]})

A ToolMessage is stored as its content, together with its artifact when it has one. status="error" is recorded as an error.

Pydantic AI 2.x (shardflux[pydantic-ai]):

from shardflux.integrations.pydantic_ai import capture_capability

agent = Agent(model, capabilities=[capture_capability(capture)])
  • Function tools and MCP toolsets are recorded with their tool_call_id. A ModelRetry is recorded as retry.
  • Provider-run (native) tools are recorded from the model response.
  • On pydantic-ai 1.x, use the decorator path instead.

CrewAI (shardflux[crewai]). This uses CrewAI's global tool hooks. CrewAI passes no call id, so don't also decorate the same tools.

from shardflux.integrations.crewai import register_hooks

unregister = register_hooks(capture)
crew.kickoff()
unregister()

MCP clients (any ClientSession or mcp.Client; no extra):

from shardflux.integrations.mcp import instrument

release = instrument(session, capture, server="github")  # recorded as "github.<tool>"; release() undoes it

Selecting tools

  • Explicit capture always records: record, call, wrap, @capture.tool and @capture_tool.
  • The hook integrations record every tool, including Shardflux's own. Narrow them with tools= / exclude=, each given as tool names, a compiled pattern or a predicate:
ws.capture_tool_calls(exclude=re.compile(r"^mcp__shardflux__"))
ws.capture_tool_calls(tools=["web_search", "sql"])
  • Calls are deduplicated by call id (the last 10,000), and the first record wins. A decorator and a hook on the same tool therefore produce one line. When the decorated function doesn't receive the call id (no context parameter), the OpenAI Agents, LangChain and Pydantic AI integrations announce each call as it starts, and the wrapper takes the id of the announced call with the same tool name and matching arguments (announcements are kept for 10 minutes, at most 1,000). CrewAI and MCP pass no id to match, so don't combine those hooks with a decorator on the same tool.
  • transform(event) -> event | None redacts or drops a call. It receives a JSON copy of {tool, call_id, source, status, error, started_at, duration_ms, input, output, meta}. If it raises, the call is dropped, never written unredacted.
  • Nothing is redacted by default. The input came from the model, so nothing in it is secret from the agent.

Layout

/home/user/tool-calls/README.md                   layout and jq recipes (written once per capture)
/home/user/tool-calls/<run>/index.jsonl           one JSON line per call, in completion order
/home/user/tool-calls/<run>/000001-web_search.json
/home/user/tool-calls/<run>/000002-fetch.html
/home/user/tool-calls/<run>/000003-screenshot.png
/home/user/tool-calls/<run>/000004-github.search/ multi-part output (MCP content, Anthropic blocks):
                                                  part-1.txt, part-2.png, result.json
/home/user/tool-calls/<run>/000005-sql.input.json an input over 64 KiB
/home/user/tool-calls/<run>/000006-scrape.json.part  output cut at max_output_bytes ("truncated": true)
  • The run id is YYYYMMDDTHHMMSSmmmZ-xxxxxx (capture.run_id, capture.run_dir). It is always generated.
  • To group runs, for example per conversation, point dir at a subdirectory: ws.capture_tool_calls(dir="/home/user/tool-calls/conv-123").
  • An index line has v, run, seq, call_id, tool, source, status (ok | error | cancelled | incomplete | retry), error, started_at, duration_ms, input (or input_path), output_path (relative to the run directory), content_type, bytes, sha256, truncated. It can also have parts, dropped and meta (merged from capture_tool_calls(meta=...) and the call's meta).
  • Read it with jq -cR 'fromjson? // empty' so a partly written line is skipped. The workspace README has recipes.

capture.prompt_hint() returns a short paragraph telling the agent where its tool results are. Add it to your system prompt if you want the agent to know; capture never injects it.

Reading your own writes, lifecycle, exit

  • On the same client, ws.exec, ws.files.*, ws.changes, snapshot, suspend, suspend_when_idle (0.5.0+), fork, close and save_as_template first wait for capture writes recorded before them. The wait is bounded by settle_timeout (30 s) and never fails the call. Traced calls show it as a capture_flush phase.
  • delete and reset discard pending writes. After a reset, the README is written again.
  • Writes resume a suspended workspace, like any tool call. With wake=False they never do: they retry for retry_window (120 s) and are then dropped as write_failed.
  • capture.flush(timeout) / await capture.aflush(timeout) wait for everything recorded so far, including on_error for its failures (0.9.0+). close() / aclose(), with and async with stop recording and flush. Shardflux.close() flushes every capture of the client.
  • At interpreter exit, one atexit handler flushes pending writes, bounded by exit_timeout (5 s).
  • Serverless platforms freeze or kill the process when the handler returns, so flush before returning (Lambda: capture.flush(), or await capture.aflush() in async handlers).
  • A forked child process (os.fork, multiprocessing with fork) gets inert captures and one forked error. Open a capture in the child if it needs one.

Limits

  • Outputs are capped at max_output_bytes (32 MiB) per call:
    • Text and JSON over the limit are cut at a UTF-8 boundary and stored as .part.
    • Binary or multi-part output over the limit is not stored (dropped: "too_large").
  • Pending writes are bounded, and nothing ever blocks the tool:
    • max_pending_bytes (128 MiB) counts everything a pending call holds: its files, its inline input and its index line. Over it, the output is dropped, and so is an input that alone doesn't fit (input null, meta.input_dropped: "queue_full"). A small index line with dropped: "queue_full" is kept; these lines may go past the limit.
    • max_pending_calls (10,000) is the hard bound for a workspace that takes no writes. Past it, a call is not recorded at all and on_error gets queue_full.
  • Each call costs about one HTTP write, plus batched index appends (every 50 ms or 1 MiB). Up to concurrency (4) writes of a capture are in flight at once: files of different calls go in parallel, so one large upload doesn't hold back later calls. Index lines are still appended in seq order, each only after its files are acknowledged. The default limits handle about 100 calls per second per workspace; raise the pending limits for more.
  • A retryable failure (network, 5xx, workspace_busy, rate_limited, disk full) retries for up to retry_window. After a network error the retry keeps its idempotency key, so a write that committed is not repeated. After an error response it moves to a new key. A 400, 403, 413 or 422 drops the item. A 404 or 410 closes the capture (the workspace is gone).
  • On template v1, captured files are read-only to the agent.
  • on_progress listeners of the client also receive the capture writers' tool events, from writer threads.

Feedback (0.5.0+)

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. Coding 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: 1-8000 characters after trimming. category: bug, confusing, missing, idea, praise or other (the default when omitted). context (every field optional text): agent, client, workspace, request_id, error_code, command, page; client defaults to shardflux-py/<version>.
  • 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.
  • An empty or too long message, an unknown category or a context that is not a mapping of strings raises ValueError / TypeError before any request. The server refuses other problems with ShardfluxApiError 422 validation_failed (details["issues"]).
  • Signed in with a CLI session instead of an API key, account.send_feedback(message, category=..., context=..., organization_id=...) (ShardfluxAccount) sends it as the user, optionally about one of your organizations.
  • Feedback is rate limited per key (or user) and per organization: ShardfluxApiError 429 rate_limited with err.retry_after (seconds). The call is never retried automatically; wait that long before sending more.

Account plane: sign in, organizations, API keys (0.5.0+)

ShardfluxAccount does what a person does in the web app, from a script or an agent: register, sign in (with MFA), organizations, projects, API keys, members, invitations, billing, spend alerts and overage, audit, exports and deletion, and template publish/archive. It authenticates with a CLI user session (sfu_<43 characters>, sent as Authorization: Bearer to /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

# Once: 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("https://app.shardflux.dev/auth/verify-email#token=...")

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)  # default: none
sf = Shardflux(api_key=key["secret"])  # the sfk_... secret is shown once

Sign up from an agent (0.10.0+)

One call gives a working account, with no browser and no email round-trip. With the Codex CLI signed in with ChatGPT on the machine, the account is verified by that login and starts with its trial:

from shardflux import ShardfluxAccount, codex_identity_proof

proof = codex_identity_proof()  # asks Codex to refresh its login, returns the ID token
if proof.ok:
    account, result = ShardfluxAccount.signup(codex_id_token=proof.id_token, accept_terms=True, on_session_token=save)
else:
    account, result = ShardfluxAccount.signup(email="me@example.com", accept_terms=True, on_session_token=save)
# result["access"]: "verified" | "provisional" | "verification_required"

if result["access"] != "verified":
    account.auth.request_email_code()  # a 6-digit code to the inbox (15 minutes)
    account.auth.verify_email_code("123456")  # verified; the session and keys keep working
  • codex_identity_proof() reads only the ID token from $CODEX_HOME/auth.json (default ~/.codex) after codex app-server refreshes it, and never raises: ok=False with reason codex_not_found, not_signed_in, api_key_login or stale. The token proves the ChatGPT account's verified email and is not a credential to OpenAI.
  • With a Codex proof, signup signs in to the account of that ChatGPT identity or email when there is one (result["created"] false), else creates it. With an email only, a provisional account works at once on the Free plan and verifying the email starts the trial; an address that has an account raises 409 conflict (details["reason"] == "email_registered").
  • accept_terms=True (0.17.0+, also on register): the person agreed to the Terms of Service and acknowledged the Privacy Policy; the new account records it, with terms_version (the terms' effective date; default: the current version).
  • account.auth.step_up(codex_id_token=...) confirms a sensitive action with the linked Codex login.

Later, ShardfluxAccount() reads SHARDFLUX_SESSION_TOKEN (and SHARDFLUX_API_URL); a token that is not sfu_<43 base64url characters> raises ValueError before any request.

  • 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 CLI session lasts 30 days from its last use and 90 days at most (the API's defaults).
  • Step-up. Exports, deletions, email and TOTP changes need a recent step-up: they raise ShardfluxApiError 403 step_up_required until account.auth.step_up(password=..., code=...) (the code only with MFA on).
  • 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) extracts it (#token= or ?token=) and raises ValueError for a link without one.
  • Upgrading a plan. A person pays in Checkout; the client waits for the subscription:
checkout = account.billing.checkout(org["id"], "developer")
print("Pay here:", checkout["url"])
done = account.billing.wait_for_checkout(org["id"], checkout["id"], timeout=900)  # polls every 2 s
done["subscription_active"]  # False when the checkout expired, was canceled or failed
account.billing.set_spend_policy(org["id"], [50, 80, 100])  # usage alerts at 50, 80 and 100 %

wait_for_checkout raises CheckoutTimeoutError when timeout passes first (the checkout stays payable). An organization that already has a subscription gets 409 conflict with err.reason == "subscription_exists": account.billing.portal(org_id)["url"] is where plans change.

  • Opt-in overage (0.6.0+). With overage on, workspaces keep opening and running past the CPU-hours and RAM GiB-hours allowances, and the usage past them is charged on the next invoice until the charges reach the spend cap (per billing period, $1 up to the plan price). Owners and billing members turn it on and set the cap:
policy = account.billing.spend_policy(org["id"])
# overage_state: unavailable | off | on | paused (a plan payment is past due);
# the cap range: spend_cap_min_minor .. spend_cap_max_minor (the plan price), in minor units (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"], spend_cap_minor=2500)  # change the cap
account.billing.set_spend_policy(org["id"], overage_enabled=False)  # always allowed

Every argument of set_spend_policy is optional (give at least one; ValueError otherwise). if_match (the version you read, or "*") makes a concurrent change a 409 conflict with reason version_mismatch instead of overwriting it. A refused change 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"]: the cap cannot go below what overage already charged this period). Every owner and billing member gets an email when overage is turned on or off or the cap changes.

Namespace Methods
class methods 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, change_password, change_email, resend_verification, totp.enroll, totp.confirm, totp.disable, totp.regenerate_recovery_codes
organizations list, list_all, create, get, entitlements, deletion, delete(id, confirmation=slug), exports.create/get/download, workspaces
projects list, list_all, create, get
api_keys list, create (with an Idempotency-Key: a retry returns the same key), 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, checkout, checkout_status, wait_for_checkout, portal, invoices, spend_policy, set_spend_policy
user (the signed-in person) deletion, schedule_deletion(confirmation=email), cancel_deletion, exports.create/get/download
templates publish_version(org_id, slug, version), archive_version(...)
audit list, list_all, export(org_id, format="ndjson" | "csv", ...) (text)
secrets the same API as sf.secrets, with the user's permissions

Plus account.me() and account.request(method, path, ...). Lists return a Page (data, next_cursor); every other method returns the API's JSON as a dict (downloads and exports as text). Organization and project ids are always explicit.

Update check (0.5.0+)

After the first successful request of a process, Shardflux or ShardfluxAccount asks GET /v1/client-versions in a background thread (one request, 3 s timeout, every error ignored, never slows a call) and emits one ShardfluxUpdateWarning (a UserWarning) when this package is outdated or no longer supported:

shardflux 0.5.0 is outdated: 0.6.0 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 passes its own identity, checked once per process too: version_check={"package": "my-tool", "version": "1.2.0"}. On demand:

from shardflux import check_client_version, compare_versions

status = check_client_version()  # ClientVersionStatus: status, current, latest, minimum_supported, message, ...
status.status  # "current", "outdated", "unsupported" or "unknown" (the request failed, or no published version)
compare_versions("0.5.0rc1", "0.5.0")  # -1: a pre-release sorts before its release

Errors

All errors derive from ShardfluxError.

  • ShardfluxApiError: the API or the workspace refused the request. It mirrors the error envelope: code, message, request_id, retryable, plus status, details, operation_id, retry_after and reason (0.2.0+) (details["reason"], e.g. workspace_not_running, not_session, legacy_disk_layout; the known ones are KnownErrorReason (0.3.0+)). Admission refusals (503 host_capacity while the workspace's host makes room to wake it, 429 quota_exceeded) are retried for any call, since nothing ran, within admission_timeout (0.18.0+). Other retryable 429/502/503/504 refusals (e.g. wake_failed) are retried after Retry-After for reads, searches and calls with an Idempotency-Key (writes, patches); other calls raise them with retryable and retry_after. A read of a sleeping workspace that its disk cannot answer (409 workspace_not_running, reason offline_unavailable or offline_budget) wakes the workspace and is retried like any workspace_not_running; 503 offline_changed is retried and served by the running workspace. 409 conflict host_feature_unavailable (search or patches are not available for the workspace, details["feature"]) is neither retried nor woken: run grep with exec, or read then write the file, instead. Two subclasses exist (0.5.0+). NotSupportedForModeError (mode, operation, local) is raised for a call the workspace's mode does not have. TreeRevisionMismatchError (current_tree_revision) is raised when an if_tree_revision change found the tree at another revision. Refusals from a file-first workspace also carry tree_revision (X-Tree-Revision). Immutable paths (0.9.0+): a files write under one is 409 conflict read_only_path (not retried; write elsewhere, or change the template); a build is refused with 422 immutable_path_removed (details["removed"]: keep them), immutable_paths_unsupported_base (details["base"]: build on a newer version of that base) or invalid_path at recipe.immutable[<i>], and a build that fails on them has failure["code"] immutable_path_missing (failure["details"]["path"]) or immutable_image_too_large.
  • OperationFailedError: an awaited operation ended failed or canceled (error_code, retryable (0.2.1+), operation). retryable is the operation error's own flag: True for capacity_unavailable (the start passed its deadline; retry it), False for a definitive failure.
  • OperationTimeoutError: waiting gave up; the operation continues (operation_id, last_state, last_reason, deadline_at (0.2.1+) while a start is queued).
  • ShardfluxProtocolError: a response was not the documented shape.
  • TemplateFileError, TemplateUploadError, TemplateBuildTimeoutError (0.3.0+): a template file or local path that cannot be used, bytes the storage refused, a build wait that ran out (see "Build a template from template.yaml").
  • CheckoutTimeoutError (0.5.0+): billing.wait_for_checkout ran out of time (checkout_id, last_status, checkout); the checkout stays payable until it expires.

Each of them has timing (0.2.0+) when a traced call (open, lifecycle call, wait, wake, token fetch) failed with it: format_timing(err.timing) says where the time went before the failure.

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)

Treat unknown error codes and reasons as generic errors: show message, and use retryable.

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 (overage is off or not on the plan: upgrade, or turn on overage), overage_paused (a plan payment is past due: update the payment method) or spend_cap_reached (raise the spend cap or upgrade), and details["spend_cap"] (cap_minor, effective_cap_minor, charges_minor, currency). An older API sends no reason. Do not retry these in a loop.

KnownErrorReason includes not_included (0.21.0+): 402 entitlement_required for a feature the plan does not include, with details["plan"], for example the GPU, which Startup and Scale include. See Errors.

Anything else

sf.me() returns the API key's organization and project. sf.secrets manages secrets and sf.templates templates, builds, uploads, file trees, diffs and drafts (see above); ShardfluxAccount (0.5.0+) the account, organizations, projects, API keys and billing. sf.request(method, path, ...) calls any /v1 route with the client's authentication, retries and error handling.

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.1 to 0.2) and are marked Breaking in the changelog.
  • shardflux.__version__ is exported; requests send User-Agent: shardflux-sdk-python/<version>.
  • From 0.5.0 the client warns once per process when it is outdated or below the API's minimum_supported version (see "Update check").
  • Examples in this README and in examples/ name the version they need.

License

Apache-2.0

Integration controls (0.8.0+)

ws = sf.open("qm/project-42", "python-node-browser", labels={"scope": "project-42"}, idle_policy="never")
matches = sf.workspaces.list(labels={"scope": "project-42"})
ws.set_labels({"scope": "project-42"})
ws.set_idle_policy("adaptive")  # None restores the inherited policy
status = ws.idle()
ws.keepalive(60)
ws.suspend_when_idle(after_seconds=2)
cell = ws.cell()
session = cell.exec_start({"session_id": "qm-worker-1", "argv": ["cat"], "stdin_open": True})
ack = cell.exec_input(session["session_id"], "hello\n", offset=0)
cell.exec_input(session["session_id"], b"", offset=ack["offset"], close=True)

Labels replace the full map; empty clears it. Frames are at most 64 KiB; continue from partial acknowledgements and retry only the identical last frame. An upgraded guest with exec_stdin.v1 is required. Native sessions count as work within the server's idle-command window. failed is recoverable through ws.wake() or an ordinary auto-waking call; recovery preserves the workspace ID and disk. Deletion frees the key only after teardown finishes; reopening then creates a new ID.

ShardfluxProtocolError.source identifies api or cell for responses. is_workspace_gone(error) only recognizes an explicit API tombstone; a bare 404, scoped not-found or network failure must never trigger deletion of local data.

Prepare tool input (0.10.0+)

workspace_tools(workspace, prewake=True) adds an optional on_input_start callback to tools that need the VM. Call the matching tool's callback when the model starts streaming its input, then execute the tool normally once its arguments are complete. The callback returns immediately and prepares the workspace in a background thread. Without prewake, no prewake thread or request starts. Offline file reads and file-first tools have no callback.

tools = workspace_tools(workspace, prewake=True)
# In your model stream's tool-input-start handler:
tool = next(tool for tool in tools if tool.name == tool_name)
if tool.on_input_start is not None:
    tool.on_input_start()

Resize controls (0.12.0+): ws.resize(memory_mib=6144, disk_gib=2, at_least=True, wait=False) grows only resources that need more capacity and returns the held server answer with its operation. ws.caps exposes typed stored caps for later starts. A pending operation carries per-resource results once the resize has decided them. Fixed memory without memory_mib uses the template default, bounded by the plan and template.

New in 0.12.0

with ref.turn() as ref: and async with ref.turn() as ref: (also ws.turn()) hint in the background and request idle suspension when the body settles, including on error. after_seconds=0 frees RAM promptly; after_seconds=60 keeps it warm for a billed minute. Use persistent workspaces for resumable turns. Async contexts offload lifecycle requests; ordinary exec/files methods remain synchronous.

Computer batches ending in screenshot or zoom return the action's image once. computer.stream() reuses a link of the same kind with more than 60 seconds left; fresh=True remints and stop_stream() clears it. computer_toolset(ws, before_action=policy) and workspace_tools(ws, computer={"before_action": policy}) call the policy immediately before each action. Return False, a refusal string, or raise to answer that action with an error and skip the rest.

Exec/execution results expose exit_code_posix (timeout 124, canceled 130, exit code, 128 + signal, otherwise 1). shardflux.FileNotFoundError inherits builtin FileNotFoundError and ShardfluxApiError for cell path_not_found replies. Environment HTTP(S)_PROXY and NO_PROXY are honored. SHARDFLUX_BASE_URL aliases SHARDFLUX_API_URL; the latter wins.

Embed a live viewer (0.12.0+)

Watch the desktop or a port preview inside your web app. Choose 1–10 distinct HTTPS origins, then use the returned URL as the iframe source.

viewer = ws.computer.stream(embed={"origins": ["https://app.example.com"]})
preview = ws.ports.link(3000, embed={"origins": ["https://app.example.com"]})
<iframe src="RETURNED_LINK_URL" title="Watch the agent" style="width:100%;height:600px;border:0"></iframe>

The browser session is partitioned by the top-level site. Each origin is exactly a scheme and host with an optional port, such as https://app.example.com:8443. The viewer permits framing by those origins; interactive viewing uses the same allowlist.

Idle deletion retention (0.12.0+)

ws = sf.open("chat/42", "python-node-browser", retention={"delete_after_idle_days": 14})
ws.set_retention({"delete_after_idle_days": 30})
sf.projects.set_retention(sf.me()["api_key"]["project_id"], {"delete_after_idle_days": 14, "labels": {"app": "chat"}})
ws.set_retention(None)  # follow the project default

sf.workspace(key, template=..., retention=...) accepts the same policy. ws.retention reports effective days and projected delete_at. All project selector labels match exactly; your workspace's own policy wins. Retention accepts 1 through 3650 days for persistent workspaces. Omit it to keep today's lifetime.

Workspace upgrade (0.12.0+)

ws.upgrade(at="now", wait=True) keeps files, installed package files and home while cold starting on the supported current layout and base. at="next_resume" schedules it. Memory and processes end at the cold start. Read ws.upgrade_available and ws.upgrade_pending for the target.

The optional Git clone filter="blob:none" (0.13.0+) fetches checkout files now and historical blobs from origin when needed. Omit it for a complete offline object store. It enables no shared cache; shallow defaults are unchanged.

Sealed-file reads (0.13.0+)

Additive: workspace.files.read_sealed_many(paths, generation=...) and cell.files_read_sealed_many(...) return ordered exact bytes and revisions from one suspended sealed generation (eight files, 1 MiB combined). read_sealed_files returns base64 bytes for agents. Explicit path errors mark an incomplete envelope; the call never wakes or replays on another owner.

Use sealed-file reads for complete files from one suspended generation. If the generation changes, request a fresh read. See lifecycle for the read contract.

Parallel Git checkout (0.13.0+)

Set parallelism (1–16) to choose Git indexing threads and checkout workers for this clone. The git_clone tool forwards it; CLI callers use git -c pack.threads=4 -c checkout.workers=4 clone ... through shard ws exec.

Treeless Git clones (0.13.0+)

Set filter: "tree:0" (Python: filter="tree:0") to fetch the requested checkout and commit graph now, with historical trees and blobs fetched from origin as needed. CLI callers use git clone --filter=tree:0 through shard ws exec. See Git for clone options.

Explicit browser readiness (0.13.0+)

The Python browser methods and both browser agent tools accept an explicit readiness declaration. Set readiness to describe the visible state needed for this observation. Omit it to wait for the full page load.

A declaration requires 1–32 DOM conditions (selector, exact equals, and optional property: "text" | "value"). Optional images (up to 32 selectors), fonts (up to 16 CSS font descriptions and sample text), and focus declare the state needed by this observation. The helper binds to the new navigation, checks declared values, decodes images and loads fonts, captures the Chromium compositor surface, then rejects changed loader or page state. A timeout or unmet condition fails the request. See browser tools for readiness and timeout behavior.

Explicit pixel completion (0.13.0+)

def press_until_pixels(ws, expected_rgba_hash):
    result = ws.computer.act(
        [{"action": "key", "text": "Return"}], screenshot=True,
        pixel_predicate={"timeout_ms": 1000, "regions": [
            {"region": [0, 0, 1280, 800], "sha256": expected_rgba_hash}
        ]},
    )
    if not result["pixel_completion"]["matched"]:
        raise RuntimeError(result["pixel_completion"]["reason"])
    return result

Explicit predicates are enabled when supplied; no environment switch is required. Omitted predicates retain ordinary 250 ms settling.

Require an appended lossless PNG and omit settle_ms. Digests cover row-major RGBA8 bytes including cursor pixels. The exact matched full frame is encoded and returned. A condition already satisfied before input refuses the batch unless allow_pre_satisfied explicitly allows that semantics. Timeout returns no success image; input may already have run, so inspect input results before retrying. Use a known, semantically unambiguous result. A pixel condition cannot certify unpainted backend state or infer completion from focus or quiet.

Exact text entry (0.13.0+)

pasteText replaces the clipboard with UTF-8 text and sends the chosen paste chord. Use shift+Insert for xterm. Ordinary type sends line breaks as Return. Start with all keys released. Clipboard replacement remains in effect after cancellation; see computer use for action results.

Initialized CPU services (0.13.0+)

Template services accept initialized_cpu with compatibility, reset, and timeout_seconds. Use cpu-service-v1 for immutable initialization, or cpu-service-fresh-inputs-v1 with fresh_env_names for values supplied at each open. Declare an unprivileged user and command readiness. Each open runs the reset script and its owned startup hooks before returning. Initialization-dependent and secret inputs use ordinary startup.

Use ws.computer.paste_text(text, paste_chord="shift+Insert", screenshot=True) for exact terminal paste, or {"action": "pasteText", "text": text, "paste_chord": "ctrl+shift+v"} in a batch.

Waiting agent reachability (0.14.0+)

A message from claude.ai or the Claude app wakes a waiting Claude conversation at any time. Reply-traffic wakes count as parked time; agent work counts as awake time. agent_reachable_seconds is deprecated and ignored (0.17.0+).

Cloud agents (0.14.0+)

Install once with npm i -g shardflux, then use shard ./ on a project, shard for the command center, shard machine for your device environment and shard template for the project. On macOS the menu bar starts automatically the first time shard runs, with launch at login enabled; shard menubar stop turns it off (CLI 0.13.2+).

Run shard ./ in your project to start Claude Code or Codex, usable immediately while setup runs in the background. Machine saves require a machine source VM (labels.shard_layer = "machine"). On your computer, run shard machine open --json, make changes in that VM, then shard machine save --wait. The computer's CLI selects the machine VM; inside a VM, save uses that VM. A conversation or unmarked fork returns filesystem_source_mismatch: copy the intended files or repeat the install in the machine VM and save there. Project keys stay in their own project; a VM's native token cannot select another workspace. machine.save(workspace_id=...) selects an existing machine source; omit workspace_id to use the remembered source.

Your machine carries credentials, tools, dotfiles, logins and agent config; the project's template carries toolchains, dependencies, services and project secrets. Both are private filesystem templates and include secrets as ordinary files. Browser logins and Claude/Codex config belong to the machine. Set up tools and secrets normally inside the VM; your agent uses the matching machine or project save/rollback commands in its source VM. Every agent starts from both. Save or roll back with shard machine save|rollback and shard template save|rollback; changed files reach running agents live, software at the next start. machine.save() and templates.save_project() return moved_to_image, the home folders the save put into the image, linked back at the same paths (0.19.0+).

shard agents fork or f in the command center copies the whole running state into an independent workspace and conversation. Open in your terminal, Claude Desktop or the Codex app, and the terminal Claude conversation in claude.ai or mobile. Sessions are titled with their task and repository, like Add CSV export · todo-app. Waiting agents stay reachable on their own connections; see the settings and billing reference. Claude needs one cloud sign-in per account; Codex uses your copied login.

Import project secrets from hsec, 1Password, Bitwarden or Keychain. Approve browser fills from the attached CLI or menu bar; remember a selected login for unattended work with automatic relay renewal. Bound secret values are redacted from command output and saved logs.

Imported secrets: stored encrypted with AWS KMS; only this project's workspaces can load it, and every load is logged. Vault fills: sent end to end from your vault to the workspace; Shardflux only relays encrypted bytes. Remembered credentials: stored encrypted with AWS KMS and filled straight into the page; agents never see it, and every use is logged.

Start a cloud agent · Machine and template · Credentials

shard templates remains the platform template build and management group.

sf.machine.get/save/rollback manages the saved machine. sf.templates.get_project/save_project/rollback_project manages a project filesystem (repo_remote, source workspace_id, and optional save settings). Save returns the version it is creating as pending; wait=True returns once that version is ready (0.14.1+), raising FilesystemSaveFailedError or, after timeout seconds (default 600), FilesystemSaveTimeoutError. FilesystemSaveFailedError carries the build's code, details and retryable (0.22.0+): a version larger than the plan's workspace disk is resource_limit with details {"limit": "template_org_bytes_max", "value", "actual"} (bytes) and retryable False; make the VM smaller or move to a plan with a larger disk, then save again. The status reports platform_base ({"slug", "version"}) and rebase_pending while a new platform version is applied (0.14.2+); rebase_deferred marks a project template that moves to the current machine when its next conversation or VM starts (0.22.0+). sf.templates.delete_project(repo_remote) (0.22.0+) deletes a project template: new conversations start from the machine until setup or save_project makes a fresh one, and VMs running on it keep their files. Fork accepts labels atomically for an independent managed agent conversation and branch.

removed = sf.templates.delete_project("https://github.com/acme/api")
removed["template"]  # {"slug": "shard-template-a84e12d2043cb2c454e18ea4", "versions": 1}

Volume dictionaries (0.15.0+) include kind (shared or project_space) and measured storage in usage.

Conversations

From 0.15.0, client.conversations.create() starts a conversation with its own VM and branch. list, get, send, wait, move and land work with its durable identity. client.device_requests handles device availability, approval and results.

Choose the model and reasoning effort the conversation's harness starts with (0.19.0+):

created = client.conversations.create(
    repo_remote="https://github.com/acme/api.git", harness="claude", route="terminal",
    base={"commit": head_sha}, model="opus", effort="xhigh",
)
created["conversation"]["model"]  # "opus"

title_prefix= (0.22.0+) sets the marker the conversation's Claude session title starts with in claude.ai and the Claude app: leave it out for the platform's ☁, "" for none, or up to 16 characters of your own.

model is the agent's own alias or model name (Claude fable, opus, sonnet, haiku; Codex gpt-6.1-sol, gpt-6-astra, gpt-6-luna) and effort a level (low, medium, high, xhigh, max; Codex also ultra). Leave them out for the agent's own defaults; a fork keeps its parent's. The conversation's model and effort say what its harness runs (None is the default), also after a resume or move.

Use join_mode="reviewer" on a join to review the parent’s worktree; ordinary joins use their own worktree. client.conversations.usage_summary() reads project usage, and request_device(id, {"kind": "try_locally"}) asks the device to try its branch locally.

Resume a saved conversation with client.conversations.resume(id) (0.15.0+). It keeps the conversation identity and restores its branch and harness transcript in a fresh VM. client.conversations.space_reader(project_id) returns a small read-only workspace and its operation for reading saved project branches and transcripts. Adaptive idle reclaims the reader.

Preparing a project ahead of its conversations (0.22.0+)

client.project_templates.prepare() opens a short-lived workspace that prepares a repository before its conversations start, the way the CLI's shard projects sync does: it mounts the project space, sits at the path a conversation's worktree uses, and opens from the project's template. setup_required says whether the dependency hash needs a new template; delete the workspace when you are done.

prep = client.project_templates.prepare("https://github.com/acme/api.git", deps_hash, worktree_id,
                                        idempotency_key=f"prepare-{deps_hash}")
# {"workspace_id": ..., "workspace_key": ..., "deps_hash": ..., "setup_required": ...}

Agent accounts (0.20.0+)

client.agent_accounts lists the organization's Claude and ChatGPT/Codex accounts, with the usage windows each one has left and when they reset. Each provider has one active account: new conversations and every running conversation VM of that provider use its login.

for a in client.agent_accounts.list()["accounts"]:
    five_hour = next((w for w in a["windows"] if w["window_minutes"] == 300), None)  # Claude five_hour, or Codex's 5-hour window
    mark = "active" if a["active"] else ""
    print(a["provider"], a["label"], mark, a["status"], five_hour and five_hour["used_percent"])

Windows are five_hour, seven_day, seven_day_opus and seven_day_sonnet for Claude, primary and secondary for Codex, each with its length in window_minutes (300: the 5-hour window, 10080: the weekly one; a plan may report only one, e.g. a ChatGPT Pro Max login's weekly window as primary). status is signed_in, low, limited or expired, and as_of says when the numbers were read. Rows are typed as AgentAccount and AgentAccountWindow; list(provider="codex") narrows the list.

Add a subscription with sign_in(provider, workspace_id): run the provider's own login in that workspace (claude, or codex login --device-auth), then pass its login files to complete_sign_in(sign_in_id, files). The person chooses the active account: activate(account_id) (0.22.0+) makes it its provider's active one and returns the updated list. Another member's personal account (scope "personal", not mine) can be made active only by that member.

accounts = client.agent_accounts.activate(account_id)["accounts"]

update(account_id, label=...) renames an account, and remove(account_id) removes it and wipes its login.

Codex Cloud (0.17.0+, unreleased)

client.codex.enable() and wait_connector() prepare one billed account executor from the copied Codex login. Use connector() to read status and disable() to remove it and release residency. After creating a conversation with harness="codex" and route="codex-cloud", prepare its returned worktree_path, then call client.codex.admit(conversation_id) and wait_task(conversation_id). task() returns the real Cloud thread id and desktop link; cancel() cancels admission and removes its owned VM. No pairing is needed. Connector memory and CPU use normal workspace billing, including idle time. The feature on this branch is awaiting release.

Save progress (0.22.0+): machine.save(wait=True, on_progress=...) and templates.save_project(..., wait=True, on_progress=...) report queue, phase and copy counters. machine.preflight(workspace_id) and templates.preflight_project(workspace_id) read source bytes/files and estimate save time. Save errors carry build_id and the last progress, including a final state: "failed" report when available. Absent counters remain unknown. If a failed build has no final host report, the last observed phase and counters are retained.

Pending execution cancellation (0.23.0+)

On guests advertising exec_cancel_pending.v1, successful cancellation can return the actual running state with canceled: true while the kernel holds a killed process. The execution's error explains pending drain, and becomes unverified after 30 seconds without claiming an exit or freeing ownership. Read/follow that session until its real final status; repeat cancellation is idempotent. An already exited foreground keeps its result while descendant drain can remain pending. TS/Python agent tools and MCP expose the reason as status_reason and keep the exit code null while active. CLI prints the reason and follow command. Older clients read the same session response fields.

Cloud task history (0.23.1+): cloud.codex.task(id) returns web_url and ordered previous_thread_ids in CodexCloudTask. An earlier ID opens at https://chatgpt.com/local/<id> with its owning Codex account.

Metadata

Release files for shardflux 0.23.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for shardflux 0.23.1
File Size Uploaded
shardflux-0.23.1.tar.gz 2.8 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for shardflux 0.23.1
File Interpreter ABI Platform
shardflux-0.23.1-py3-none-any.whl Python 3 none any Details

Total release size: 5.2 MB

Release files / shardflux-0.23.1.tar.gz

Download URL shardflux-0.23.1.tar.gz
Size 2.8 MB
Tags Source
SHA-256 checksum
How to use checksums
405acfd360b88ce24ea5045ee55ca68b1a7c26be4a81025999abeefe63323240
BLAKE2b-256 checksum
How to use checksums
27546325a07e25e63301fc840a7d45164f22bce2763a3e9c140160c29d1f301f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / shardflux-0.23.1-py3-none-any.whl

Download URL shardflux-0.23.1-py3-none-any.whl
Size 2.4 MB
Tags Python 3
SHA-256 checksum
How to use checksums
8c7b265596ed6fccb83130ad344916c049d9559790a40a8c9804a290fc1f6ff6
BLAKE2b-256 checksum
How to use checksums
8b65a471dc779de41b43400d5978b0cfc14576bfa2038601f1d13b7a89181d82
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page