Metadata-Version: 2.4
Name: shardflux
Version: 0.2.0
Summary: Python client for Shardflux: cloud computers for AI agents. Persistent workspaces you open by key, run commands in, suspend, resume and fork.
Project-URL: Homepage, https://shardflux.dev
Author: Shardflux
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agents,ai-agents,code-execution,microvm,sandbox,sdk,shardflux,workspace
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Description-Content-Type: text/markdown

# shardflux

Python client for [Shardflux](https://shardflux.dev): cloud computers 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.

> **Early access.** Shardflux is in early access. The API is versioned (`/v1`), but this client is
> below 1.0: a minor release may contain breaking changes.

> **Versions.** This README describes 0.2.0. Anything marked **(0.2.0+)** is not in 0.1.0; 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`).
- Typed (`py.typed`).
- Retries, idempotency keys, operation waits and tool-token refresh are handled for you.

## Install

```sh
pip install shardflux
```

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

```python
from shardflux import Shardflux, format_timing

sf = Shardflux()  # reads SHARDFLUX_API_KEY; or Shardflux(api_key="...")


def open_workspace():
    return sf.open(key="customer-42/main", template="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.

## 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) |
| `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") |

`Shardflux` is a context manager (`with Shardflux() as sf: ...`); `close()` closes the HTTP client
it created.

## Commands

```python
r = ws.exec("pip install requests && python3 app.py", cwd="/home/user/project", env={"DEBUG": "1"}, timeout=600)
r = ws.exec(["python3", "-V"])  # a list runs as argv, without a shell

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

## Files

```python
ws.files.write("/home/user/data.bin", b"\x00\x01\x02", create_parents=True)
data = ws.files.read("/home/user/data.bin")  # bytes, the whole file
text = ws.files.read_text("/home/user/notes.txt")
ws.files.list("/home/user")  # {"entries": [...], "truncated": False}
ws.files.stat("/home/user/notes.txt")
ws.files.remove("/home/user/data.bin")
```

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

## Lifecycle

```python
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; keys are never reused

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` |

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

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+)**.

### 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.
- 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).
- `ws.cell(wake=None)` returns `workspace_not_running` instead.

```python
cell = ws.cell(transition_timeout=30)  # give up waking after 30 s
cell.exec_run(["make", "test"])  # resumes the workspace first if it is suspended
```

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

```python
from shardflux import format_timing

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

A slow open (34 s instead of the usual second) then reads, for example:

```text
open 34.18 s, succeeded (workspace 01a0e5a8-3edd-74ba-b489-d62b8925e342, operation 01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33)
  client: request 20.01 s (held) → capacity_pending 13.52 s (no_ready_host) → running 590 ms → view 42 ms ∥ token 61 ms
  server: queued 33.40 s, ran 620 ms, total 34.02 s; start warm, boot to ready 79 ms
  outside the server: 160 ms
```

Here the time went to waiting for a host with capacity; the network and the VM start were fast.

- **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`: waiting for a host; `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 wait for capacity; `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.
- **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=...)`):

```python
import sys

from shardflux import ProgressEvent, Shardflux, format_timing


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

```python
# 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`, `dev_template_id`
and `update_policy`. Reset, `changes()` and save-as-template need a `layered` workspace.

## Templates (0.2.0+)

```python
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.

## 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`.

```python
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.

## 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`).
- `OperationFailedError`: an awaited operation ended `failed` or `canceled` (`error_code`,
  `operation`).
- `OperationTimeoutError`: waiting gave up; the operation continues (`operation_id`,
  `last_state`, `last_reason`).
- `ShardfluxProtocolError`: a response was not the documented shape.

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.

```python
from shardflux import ShardfluxApiError

try:
    sf.open(key="customer-42/main", template="python-node-browser")
except ShardfluxApiError as err:
    print(err.code, err.reason, err.message, err.request_id, err.retryable)
```

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

## Anything else

`sf.me()` returns the API key's organization and project. `sf.secrets` manages secrets and
`sf.templates` template file trees, diffs and drafts (see above). `sf.request(method, path, ...)` calls
any `/v1` route with the client's authentication, retries and error handling. The TypeScript SDK
(`npm install @shardflux/sdk`) covers the full API, including agent tool definitions for model
providers.

## Compatibility

- The client follows the API's `/v1` contract. New fields, enum values and error codes can appear
  in any release; ignore unknown fields.
- While below 1.0, a breaking change bumps the minor version (0.1 to 0.2).
- `shardflux.__version__` is exported; requests send `User-Agent: shardflux-sdk-python/<version>`.
- Examples in this README and in `examples/` name the version they need.

## License

Apache-2.0
