# Python SDK reference

Every public class and method of the Python SDK (`shark`, version 0.1.0): signature, parameters, return type, the
errors it raises, and a short example. Python 3.9+, standard library only. Checked against the code by
`proofs/a12-docs-acceptance/sdkcheck.py` (it fails when this page and `sdk/python` drift apart). The TypeScript SDK:
[sdk-typescript.md](sdk-typescript.md). The HTTP routes behind each call: [api.md](api.md#route--cli--sdk).

Status follows [api.md](api.md): **GA-path** (served and tested), **beta** (served; local-only tests or a gap noted),
**coming** (answers `422 unsupported` today). A method has the status of the route it calls; it is marked below
where it is not GA-path.

## Install

```sh
<PY_SDK_INSTALL>
```

The package is not on PyPI yet; the placeholder is explained in [quickstart.md](quickstart.md#0-what-you-need). It
imports as `shark`.

## Authentication

Put your **account key** (`shk_live_…`) in the environment and construct the client with no arguments:

```sh
export SHARK_API_KEY=...        # never in code, in a file of the Space, or in an agent's prompt
```

```python
from shark import Shark
shark = Shark()
```

The client sends the key only to the account service (`https://api.sharkmagic.si`), which returns a 5-minute node
token; the SDK refreshes it 60 s before it expires, finds the node that holds each Space, and sends the call there.
The key never reaches a node. Without `SHARK_API_KEY`, the SDK reads `~/.shark/credentials` (written by
`shark login`; ignored unless its mode is `0600`).

**Leave `SHARK_API` unset.** `SHARK_API` (or `base_url=`) is *direct mode*, for a self-hosted node and that node's
own keys; an account key there answers `401 unauthenticated`. Mode order: `control_plane=` > `base_url=` >
`$SHARK_CP` > `$SHARK_API` > the credentials file's `SHARK_CP` / `SHARK_API` > `https://api.sharkmagic.si`.

## Errors

Every API error raises a subclass of `shark.SharkError` with these attributes: `code` (the stable code from
[errors.md](errors.md); branch on it), `status` (HTTP), `message`, `request_id`, `details`, `retryable`,
`retry_after` (seconds, from `Retry-After`). A code the SDK does not know maps by HTTP status (400 →
`InvalidRequestError`, 401 → `AuthenticationError`, 402 → `CreditsExhaustedError`, 403 → `PermissionDeniedError`,
404 → `NotFoundError`, 409/412 → `ConflictError`, 410 → `OpExpiredError`, 413 → `InvalidRequestError`, 422 →
`UnsupportedError`, 429 → `RateLimitError`, 5xx → `ServerError`).

**Retries are built in.** Connection errors, `429` and `5xx` marked retryable are retried (up to `max_retries`,
honouring `Retry-After`; `lease_cap_reached` keeps waiting up to `queue_timeout`), always with the same `op_id` or
`Idempotency-Key`, so a command never runs twice and a create never makes two Spaces. Only the final error reaches
you. **A non-zero exit is not an exception**: `run()` returns it in `ExecResult.exit` (unless `check=True`).

| class | extends | API codes | raised when |
|---|---|---|---|
| `SharkError` | `Exception` | | base of all; also `code="config"` (no API key, bad URL, `balance()` in direct mode), `code="needs_control_plane"` (`github_app` in direct mode) |
| `AuthenticationError` | `SharkError` | `unauthenticated`, `auth_rate_limited` | bad, revoked or expired key; account key in direct mode |
| `PermissionDeniedError` | `SharkError` | `insufficient_scope`, `plan_restriction`, `tenant_not_allowed` | the key lacks a scope (`details.required`) or the account the feature |
| `NotFoundError` | `SharkError` | `not_found` | unknown id or name, or another account's Space |
| `ConflictError` | `SharkError` | `conflict`, `precondition_failed` | state does not allow it; `If-Match` mismatch |
| `IdempotencyMismatchError` | `ConflictError` | `idempotency_mismatch`, `idempotency_in_progress` | an `op_id` / key reused for a different request |
| `OpExpiredError` | `SharkError` | `op_expired` | the op's stored output is past its 7 days |
| `InvalidRequestError` | `SharkError` | `invalid_request`, `invalid_op_id`, `path_outside_space`, `payload_too_large`, `upgrade_required` | fix the request (`details.field`, `details.max_s`) |
| `UnsupportedError` | `SharkError` | `unsupported` | a route this node does not serve yet (`details.phase`) |
| `RateLimitError` | `SharkError` | `rate_limited` | request rate per key, after the retries |
| `LeaseCapError` | `RateLimitError` | `lease_cap_reached` | concurrent commands at the cap after `queue_timeout`; the command did not start; `.scope` = `details.scope` |
| `SpaceLimitError` | `RateLimitError` | `space_limit_reached` | the node's live-Space cap |
| `QuotaExceededError` | `SharkError` | `quota_exceeded`, `insufficient_storage` | an account cap (`details.quota`: `spaces`, `awake`) or disk |
| `CreditsExhaustedError` | `SharkError` | `credits_exhausted` | balance at zero; reads keep working |
| `ServerError` | `SharkError` | `internal` | after the retries; quote `request_id` |
| `CapacityError` | `ServerError` | `capacity_unavailable`, `out_of_capacity`, `canceled` | the node was too busy; **the command did not run** |
| `DurableUnavailableError` | `ServerError` | `durable_unavailable` | `checkpoint()` could not reach the durable store |
| `ConnectionError` | `SharkError` | | the network failed after the retries (`code="connection"`); `shark.ConnectionError`, not the built-in |
| `CommandFailedError` | `SharkError` | | `run(..., check=True)` and the exit is not 0 (`code="command_failed"`; `.exit`, `.stdout`, `.stderr`, `.op_id`, `.result`) |
| `InfraError` | `SharkError` | | `run()` got `shark_status: infra_error` twice for the same `op_id` (`code="infra_error"`) |
| `CommandTimeoutError` | `SharkError` | | `run()` got `shark_status` `timeout` or `infra_timeout` (`code` is that status) |
| `WaitTimeoutError` | `SharkError` | | `op.wait(timeout=...)` ran out of time (`code="wait_timeout"`, `.op_id`, `.timeout`); the command keeps running |

```python
from shark import Shark, CreditsExhaustedError, QuotaExceededError, SharkError

try:
    space = shark.spaces.create(name="job-1")
except QuotaExceededError as e:
    print(e.code, e.details.get("quota"))     # spaces | awake: delete or hibernate a Space
except CreditsExhaustedError:
    print("top up; files stay readable")
except SharkError as e:
    print(e.code, e.request_id)
```

## Client

### `Shark`

```python
Shark(api_key=None, base_url=None, timeout=60, max_retries=5, queue_timeout=600, ca_file=None, control_plane=None)
```

The synchronous client. `api_key`: default `$SHARK_API_KEY`. `base_url`: a self-hosted node (direct mode).
`control_plane`: the account service URL (default `https://api.sharkmagic.si`, or `$SHARK_CP`). `timeout`: seconds
per HTTP request (commands get their own). `max_retries`: retries of a retryable failure. `queue_timeout`: seconds to
keep retrying `lease_cap_reached`. `ca_file`: a CA bundle for a self-hosted node with its own certificate (also
`$SHARK_API_CA`). Raises `SharkError(code="config")` without a key or with a bad URL (plain `http` only to loopback).
Attributes: `spaces`, `secrets` (account secrets), `space_defaults`, `github_app`, `keys`, `usage`. Usable as a
context manager (`with Shark() as shark:`).

### `shark.me`

```python
shark.me() -> dict
```

Your account (`tenant`), plan, and the credential's id, kind, scopes and expiry (`GET /me`).

### `shark.capabilities`

```python
shark.capabilities() -> dict
```

The node's API version, features, limits and lease shapes (`GET /capabilities`). Read features from here rather than
assuming them.

### `shark.limits`

```python
shark.limits() -> List[dict]
```

Every limit that applies to you with current use: `[{"name", "scope", "limit", "used"}, …]` (`GET /quotas`; also the
`node_admission` row). Read limits from here, never from memory ([limits.md](limits.md)).

```python
caps = {q["name"]: (q["used"], q["limit"]) for q in shark.limits()}
```

### `shark.close`

```python
shark.close() -> None
```

Closes the pooled connections. Called by `with Shark() as shark:` on exit.

### `shark.transport`

```python
shark.transport -> Transport
```

The low-level transport (`transport.request(operation_id, params, query=…, json_body=…)` sends one call of
`shark.ROUTES` with the retry policy above; `transport.stats` counts requests, retries and reconnects). Not covered
by this reference; prefer the methods below.

### `new_op_id`

```python
new_op_id() -> str
```

A fresh time-ordered id (UUIDv7 layout). The SDK generates one per call when you pass none; in an agent loop pass
your tool-call id as `op_id` instead, so a retried tool call replays rather than runs twice.

## Spaces

A Space is a persistent workspace; each command runs in a fresh microVM with it mounted
([concepts.md](concepts.md#space)).

**SDK Spaces.** Spaces the SDK creates are **sdk** Spaces: nothing of your account reaches them unless the Space asks.
Every option below can be set at create, changed with `space.update(...)`, and overridden per fork; leave it out for
the default, pass `None` to `update` to go back to the profile's default ([api.md](api.md#sdk-spaces-and-devbox-spaces-profile)).

| option | sdk default | values |
|---|---|---|
| `profile` | `"sdk"` | `"sdk"` or `"devbox"` (as `shark create` / `shark code`: the account's secrets and GitHub, background jobs, the Space CLI); create only |
| `account_secrets` | `"none"` | `"all"` (the account's `apply: all` secrets), `"none"` (only those the Space references), or a list of names |
| `github` | `False` | `True`: the account's GitHub connection (a managed `GITHUB_TOKEN`; connect it once with `shark github connect`, [cli.md](cli.md#github)) |
| `git` | none | `{"name": …, "email": …}`: the Space's commit identity |
| `space_cli` | `False` | `True`, or a list of scopes (subset of `spaces:read`, `files:read`, `exec`, `usage:read`): a Space credential for the `shark` CLI inside the Space |
| `keep_background` | `False` | `True`: a process a command leaves running (`cmd &`) stays as a job (beta, [background jobs](#background-jobs-processes)) |
| `keep_awake` | `False` | `True`: kept jobs never sleep when idle (**billed awake** the whole time) |
| `egress` | node default | a policy dict, or `"deny"`, `"coding"` (package registries and code hosts), `"open"` ([egress](#egress)) |
| `labels` | none | `{"k": "v"}`; `shark.spaces.list(label="k:v")` filters |

Changing `account_secrets`, `github` or `git` needs the `secrets:write` scope; `keep_background`, `keep_awake` and
`space_cli` need `exec`; `egress` needs `network:write`. A refused value is `InvalidRequestError` with
`details.field`, and nothing of the request is applied.

### `shark.spaces.create`

```python
shark.spaces.create(*, name=None, labels=None, egress=None, env=None, from_space=None, at=None, idempotency_key=None, tenant=None, profile=UNSET, account_secrets=UNSET, github=UNSET, git=UNSET, keep_background=UNSET, keep_awake=UNSET, space_cli=UNSET) -> Space
```

Creates a Space (`POST /spaces`). `name`: unique in your account, usable in place of the id. `env`: non-secret
variables for every command. `from_space` / `at`: start from another Space's content (at a commit), O(1).
`idempotency_key`: generated if absent; the SDK retries with the same key, so a retried create never makes two
Spaces. `tenant`: leave unset (the account comes from the key; another value is `PermissionDeniedError`
`tenant_not_allowed`). `UNSET` means "not sent": the profile's default applies. Raises `QuotaExceededError`
(`details.quota` `spaces` or `awake`), `CreditsExhaustedError`, `CapacityError`, `ConflictError` (duplicate name),
`InvalidRequestError`.

```python
space = shark.spaces.create(name="acme-1", labels={"customer": "acme"}, egress="coding",
                            account_secrets=["OPENAI_API_KEY"], git={"name": "Acme Bot", "email": "bot@acme.test"})
```

### `shark.spaces.get`

```python
shark.spaces.get(id_or_name) -> Space
```

One Space by id (`tti-…`) or name; does not wake it. Raises `NotFoundError`.

### `shark.spaces.list`

```python
shark.spaces.list(*, label=None, state=None, name=None, limit=100) -> Iterator[Space]
```

Every matching Space, newest first, pages followed for you; does not wake hibernated ones. `label`: `"k:v"` or a
list (all must match). `state`: `"ready"` or `"hibernated"`. `limit`: page size.

```python
for s in shark.spaces.list(label="customer:acme"):
    print(s.id, s.state)
```

### `space.id`

```python
space.id -> str
```

The Space id (`tti-<32 hex>`).

### `space.state`

```python
space.state -> str
```

`ready` or `hibernated`, as of the last response (`space.refresh()` to update).

### `space.labels`

```python
space.labels -> Dict[str, str]
```

### `space.profile`

```python
space.profile -> str
```

`sdk` or `devbox` (Spaces made before profiles report `devbox`).

### `space.refresh`

```python
space.refresh() -> Space
```

Re-reads the Space (`GET /spaces/{id}`), including `settings`. `space.data` holds the raw JSON.

### `space.settings`

```python
space.settings() -> dict
```

Every per-Space setting with its source: `name`, `labels`, `env`, `egress` (with `effective.hosts`), `dormancy`,
`secrets` (names only), `ports`, `durable`, `profile`, `account_secrets`, `github`, `git`, `keep_background`,
`keep_awake`, `space_cli` (each `{value, source}`), `limits` ([api.md](api.md#spaces)).

```python
print(space.settings()["account_secrets"])     # {'value': ['OPENAI_API_KEY'], 'source': 'space'}
```

### `space.update`

```python
space.update(*, name=None, labels=None, env=None, egress=None, account_secrets=UNSET, github=UNSET, git=UNSET, keep_background=UNSET, keep_awake=UNSET, space_cli=UNSET) -> Space
```

Changes settings in one `PATCH /spaces/{id}`: every field is validated before any is applied. `labels` and `env`
replace the whole map. `None` for an account option sets it back to the profile's default; `None` for `name`,
`labels`, `env`, `egress` means "unchanged". Raises `InvalidRequestError` (`details.field`), `PermissionDeniedError`
(`insufficient_scope`, or `plan_restriction` for egress `open`).

```python
space.update(github=True, keep_background=None)
```

### `space.fork`

```python
space.fork(count=1, labels=None, name_prefix=None, at=None, idempotency_key=None, inherit_labels=True, account_secrets=UNSET, github=UNSET, git=UNSET, keep_background=UNSET, keep_awake=UNSET, space_cli=UNSET) -> List[Space]
```

`count` (1–50) copies of the Space at HEAD (or at commit `at`): files O(1); `env` and egress copied; the Space's own
secrets, processes and service memory not (account secrets reach a fork as any Space of the account). Forks keep the
source's profile and account options unless you pass them. `inherit_labels=False`: the forks get only `labels`.
Retried with one `Idempotency-Key`. Raises `QuotaExceededError`, `InvalidRequestError` (count), `NotFoundError`.

```python
attempts = space.fork(count=3, labels={"try": "1"})
results = [f.run("pytest -q").exit for f in attempts]
```

### `space.hibernate`

```python
space.hibernate() -> Space
```

Hibernates now (any later request wakes it). Raises `ConflictError` while something runs.

### `space.wake`

```python
space.wake() -> dict
```

Wakes now; the result has `wake_ms`. Not needed before a command: any request wakes the Space.

### `space.checkpoint`

```python
space.checkpoint() -> dict
```

Returns once HEAD and its objects are in the durable store. Raises `DurableUnavailableError`.

### `space.delete`

```python
space.delete(purge=False) -> None
```

Waits for the running command, then deletes the Space; `purge=True` also deletes the durable copy. Deleting a Space
that is already gone is not an error.

### `space.audit`

```python
space.audit() -> dict
```

Commits from HEAD with their `op_id`, changed paths and exit (`GET /spaces/{id}/audit`).

### `space.usage`

```python
space.usage(start=None, end=None) -> dict
```

This Space's metered usage (beta; `GET /spaces/{id}/usage?from&to`); see [usage](#usage-quotas-and-balance).

## Commands

Three ways to run a command, all exactly once per `op_id` ([concepts.md](concepts.md#op-and-op_id-exactly-once)):
`run` waits (sync; `timeout` default 120 s, max 900), `stream` yields live events, `start` returns an `Op` at once
(stream and async: default 1,800 s, max 7,200). A `timeout` above the maximum is `InvalidRequestError` with
`details.max_s`. Common parameters:

| parameter | meaning |
|---|---|
| `cmd` | a bash command line (≤ 256 KiB), run in a fresh microVM with the Space mounted; file changes are committed whatever the exit code |
| `timeout` | seconds before the command is stopped |
| `cwd` | directory relative to the Space root |
| `env` | non-secret variables for this command |
| `stdin` | `str` or `bytes` (≤ 1 MiB) |
| `op_id` | the command's id (1–64 of `[A-Za-z0-9_.:-]`); generated if absent. Use your tool-call id |
| `background` | `True`: what the command leaves running (`cmd &`) stays as a job; `False`: it ends with the command; `None`: the Space's `keep_background` (sdk: off) |
| `secrets` | names of the Space's `exec`-exposed secrets to put in this command's environment |

### `space.run`

```python
space.run(cmd, *, timeout=120, cwd=None, env=None, stdin=None, op_id=None, check=False, on_stdout=None, on_stderr=None, retry_conflict=False, request_timeout=None, background=None, secrets=None) -> ExecResult
```

Runs one command and waits. `check=True` raises `CommandFailedError` on a non-zero exit. `on_stdout` / `on_stderr`:
callbacks with each chunk of text as it arrives (the call then streams). `retry_conflict=True`: on `shark_status:
"conflict"` (a concurrent write to the same paths) re-run once with a new `op_id`. `request_timeout`: the HTTP wait
(default `timeout + 30`). An `infra_error` is retried once with the same `op_id`; a node too busy to start the
command is retried after its `Retry-After`. Raises `InfraError`, `CommandTimeoutError`, `CapacityError` (still busy:
the command did not run), `CommandFailedError` (with `check`), and the API errors (`LeaseCapError`,
`CreditsExhaustedError`, `NotFoundError`, …).

```python
r = space.run("npm ci && npm test", cwd="web", env={"CI": "1"}, timeout=900, op_id=tool_call.id)
print(r.exit, r.shark_status, r.stdout[-2000:])
```

### `space.stream`

```python
space.stream(cmd, *, timeout=1800, cwd=None, env=None, stdin=None, op_id=None, idle_timeout=45.0, background=None, secrets=None) -> Iterator[Event]
```

Live events: `start`, `stdout`, `stderr`, `detached`, then `exit` (the result) or `error`. A dropped connection
resumes from the last event id: each event is yielded exactly once. `idle_timeout`: seconds of silence (the server
pings every 15 s) before reconnecting. Raises `ConnectionError` when the stream cannot be resumed.

```python
for ev in space.stream("make -j8"):
    if ev.type in ("stdout", "stderr"):
        print(ev.text, end="")
    elif ev.type == "exit":
        print("exit", ev.exit)
```

### `space.start`

```python
space.start(cmd, *, timeout=1800, cwd=None, env=None, stdin=None, op_id=None, background=None, secrets=None) -> Op
```

Starts the command and returns its `Op` at once (`"wait": false`, `202`). Detach and come back later, from this or
any other process, with `space.op(op_id)`.

```python
op = space.start("python train.py", timeout=7200)
save(op.op_id)                       # later: space.op(op_id).wait()
```

### `space.op`

```python
space.op(op_id) -> Op
```

Re-attaches to an op by id. Raises `NotFoundError`, `OpExpiredError` (output past 7 days).

### `space.ops`

```python
space.ops(state=None, limit=50) -> List[dict]
```

Recent ops, newest first (`state`: `"running"` or `"done"`).

### `op.state`

```python
op.state -> str
```

`running` or `done`, as of the last response. `op.op_id`, `op.space` and `op.data` (the raw op) are attributes.

### `op.refresh`

```python
op.refresh() -> Op
```

Re-reads the op: `state`, `cmd`, `result`.

### `op.events`

```python
op.events(after=0, idle_timeout=45.0) -> Iterator[Event]
```

Every event after event id `after`, live or after the op ended (kept 7 days), each exactly once.

### `op.wait`

```python
op.wait(poll=0.5, timeout=None) -> ExecResult
```

Waits for the result. `timeout=None` follows the event stream to the end (`poll` is not used). With `timeout`
(seconds) it checks the op every `poll` seconds and raises `WaitTimeoutError` (`code="wait_timeout"`, `.op_id`,
`.timeout`) if the op is still running at the deadline: **the command keeps running**; wait again, read `op.state`
after `op.refresh()`, or stop it with `op.signal()`. A negative `timeout` or a `poll` of 0 or less raises `ValueError`.

```python
from shark import WaitTimeoutError

op = space.start("make test", timeout=1800)
try:
    res = op.wait(timeout=60, poll=1)
except WaitTimeoutError:
    op.signal("INT")
```

### `op.output`

```python
op.output() -> str
```

The op's full stored stdout (up to the op log's cap, 32 MiB per stream).

### `op.signal`

```python
op.signal(sig="TERM") -> None
```

Sends `TERM`, `INT`, `KILL` or `HUP` to the running command.

### `ExecResult`

| field | meaning |
|---|---|
| `op_id` | the command's id |
| `exit` | the exit code (non-zero is a normal result) |
| `shark_status` | `ok`, `conflict` (re-run with a new `op_id`), `timeout`, `infra_timeout`, `infra_error`, `capacity_unavailable`, `signaled` ([concepts.md](concepts.md#lease-one-command-one-microvm)) |
| `stdout`, `stderr` | output (sync: the last 1 MiB of each stream) |
| `stdout_truncated` | `True` when stdout was cut; `op.output()` has the rest |
| `stderr_truncated` | the same for stderr |
| `commit` | the commit the command made, or `None` when it changed no file |
| `changed_paths` | number of paths changed |
| `wall_ms` | time at the node, end to end |
| `queue_ms` | time waiting for a slot |
| `wake_ms` | set when the request woke the Space |
| `run_s` | the command's own run time |
| `replayed` | `True` when this is the stored result of an earlier request with the same `op_id` |
| `processes` | jobs the command left running (with `background`) |
| `raw` | the response JSON |

### `event.id`

```python
event.id -> Optional[str]
```

The event's sequence number (resume point).

### `event.type`

```python
event.type -> str
```

`start`, `stdout`, `stderr`, `detached`, `exit`, `error` (and, for `files.watch()`, `change`).

### `event.data`

```python
event.data -> dict
```

The event's JSON.

### `event.text`

```python
event.text -> str
```

A `stdout`/`stderr` chunk as text (base64 chunks decoded, invalid UTF-8 replaced).

### `event.bytes`

```python
event.bytes -> bytes
```

The same chunk as bytes.

### `event.exit`

```python
event.exit -> Optional[int]
```

The exit code on an `exit` event.

## Files

Reads come from the committed tree **without starting a VM** (and without waking a hibernated Space). Writes are ops:
one commit each, exactly once per `op_id`; a write that races a command on the same path is `ConflictError`
(`details.paths`). Paths are relative to the Space root; one that escapes it is `InvalidRequestError`
(`path_outside_space`). `at`: read at a past commit.

### `space.files.read`

```python
space.files.read(path, at=None) -> bytes
```

Raises `NotFoundError`.

### `space.files.read_text`

```python
space.files.read_text(path, at=None, encoding="utf-8") -> str
```

```python
print(space.files.read_text("out/report.json"))
```

### `space.files.write`

```python
space.files.write(path, data, *, mode=None, op_id=None, if_match=None) -> dict
```

Writes one file (`str` or `bytes`, ≤ 512 MiB) as one commit; parents are created. `mode`: e.g. `"0755"`.
`if_match`: an etag from `stat()`: compare-and-swap, `ConflictError` (`precondition_failed`) if the file changed.
Raises `InvalidRequestError` (`payload_too_large`), `QuotaExceededError` (`insufficient_storage`).

### `space.files.stat`

```python
space.files.stat(path, at=None) -> dict
```

`{"type", "size", "mode", "mtime", "etag", "commit"}` without the content. Raises `NotFoundError`.

### `space.files.exists`

```python
space.files.exists(path) -> bool
```

### `space.files.list`

```python
space.files.list(path="/", depth=1, glob=None, at=None) -> List[FileEntry]
```

The tree under `path` (pages followed). `glob`: e.g. `"**/*.py"`.

### `space.files.remove`

```python
space.files.remove(path, recursive=False, op_id=None) -> dict
```

### `space.files.move`

```python
space.files.move(src, dst, overwrite=False, op_id=None) -> dict
```

### `space.files.upload_dir`

```python
space.files.upload_dir(local, remote="/", replace=False, op_id=None) -> dict
```

Uploads a local directory as one commit (tar + gzip, ≤ 2 GiB). `replace=True`: `remote` becomes exactly the upload.

```python
space.files.upload_dir("./repo", "repo")
```

### `space.files.download_dir`

```python
space.files.download_dir(remote, local, at=None) -> int
```

Downloads a directory into `local` and returns the number of files. Unsafe archive members (absolute paths, `..`)
are refused.

### `space.files.watch`

```python
space.files.watch(path=None, after_commit=None) -> Iterator[Event]
```

One `change` event per landed commit (`commit`, `op_id`, `paths`), from `after_commit` or now. Runs until you stop
iterating.

### `FileEntry`

| field | meaning |
|---|---|
| `path` | relative to the Space root |
| `type` | `file`, `dir` or `symlink` |
| `size` | bytes |
| `mode` | e.g. `0644` |
| `mtime` | modification time |
| `hash` | content hash |
| `target` | a symlink's target |

## Background jobs (processes)

**Beta.** On a node that keeps background jobs, a process a command leaves running (with `background=True`, or a
Space with `keep_background=True`) becomes a job `bg-1`, `bg-2`, … in its own VM; later commands reach its ports at
`127.0.0.1:<port>` ([concepts.md](concepts.md#background-processes)). **A job is billed (process seconds) while it is
awake**, and with `keep_awake=True` it never sleeps: stop jobs you no longer need.

### `space.processes.list`

```python
space.processes.list() -> List[dict]
```

Jobs: `id` (`bg-1`), `op_id`, `state` (`running`, `paused`, `snapshotted`, `exited`), `ports`, `exit`.

### `space.processes.get`

```python
space.processes.get(pid) -> dict
```

### `space.processes.logs`

```python
space.processes.logs(pid, tail_bytes=None) -> str
```

Its output (committed log: it grows at the job's snapshots and at its end).

### `space.processes.signal`

```python
space.processes.signal(pid, sig="TERM") -> None
```

Stops it through its keeper, so its writes land.

```python
space.run("(timeout 1800 python -m http.server 8000 > srv.log 2>&1 &)", background=True)
print(space.run("curl -s localhost:8000 | head -1").stdout)
for p in space.processes.list():
    space.processes.signal(p["id"])
```

## Exposed processes and previews

**Live on the hosted node** (beta). A server declared on a port starts on the first request, sleeps after `idle_s`
(default 30 s) without traffic, wakes on the next request, follows HEAD (hot reload sees every commit) and is reachable
at `https://<space-id>-<port>.preview.sharkmagic.si`. **Private by default**: only a browser that opened a one-time
login link from the account sees it; `public=True` opens it to anyone with the URL. An exposed process is **billed
(service seconds) while it is awake**, by size, and not while asleep: remove the ones you no longer need, and use
`shark.ports.list(state="running")` to find what is awake. The account caps how many run at once (`awake_services` in
`shark.limits()`); a start past it is refused. A node without the ports runner raises `UnsupportedError`
([api.md](api.md#exposed-processes-previews-and-the-terminal)).

### `space.ports.declare`

```python
space.ports.declare(port, cmd, *, cwd=None, env=None, idle_s=None, public=None) -> dict
```

Declares (or replaces) the server on `port` (`PUT /spaces/{id}/ports/{port}`); returns the exposure. Replacing one
restarts it. `env` is for non-secret values (secrets go through `space.secrets`).

```python
p = space.ports.declare(3000, "npm run dev", cwd="app", idle_s=60)
print(p["url"], p["state"])                       # https://<id>-3000.preview.sharkmagic.si declared
link = space.ports.login_link(3000)["login_url"]  # hand it to a person; it works once
```

### `space.ports.list`

```python
space.ports.list() -> List[dict]
```

Every exposure of the Space: `port`, `state` (`declared`, `starting`, `running`, `asleep`, `exited`, `failed`), `url`,
`cmd`, `cwd`, `idle_s`, `public`, `created_at`, `connections`, `starts`, `wakes`, `sleeps`, `last_request_at` (null =
no request since the node started), `awake_since` (null unless running), `exit_code`, `error`.

### `space.ports.get`

```python
space.ports.get(port) -> dict
```

One exposure (`NotFoundError` when the port is not declared).

### `space.ports.set_public`

```python
space.ports.set_public(port, public) -> dict
```

`True`: anyone with the URL reaches it (and every request can wake it); `False`: one-time links only.
`PermissionDeniedError` (`plan_restriction`) where public previews are off.

### `space.ports.sleep`

```python
space.ports.sleep(port) -> dict
```

Puts it to sleep now (no compute billed while asleep); the next request wakes it.

### `space.ports.logs`

```python
space.ports.logs(port) -> str
```

The process's output (not part of the Space's tree).

### `space.ports.remove`

```python
space.ports.remove(port) -> None
```

Stops it and revokes every preview cookie: open preview tabs stop working.

### `space.ports.login_link`

```python
space.ports.login_link(port, path=None) -> dict
```

`{url, public, login_url, expires_at}`: `login_url` logs in whoever opens it, once, within 60 s, and sets a cookie for
that preview host only. Generate it when a person is about to open it; never store, log or paste it.

### `shark.ports.list`

```python
shark.ports.list(state=None) -> List[dict]
```

Every exposure of the account on its node (`GET /ports`), awake first; each entry as `space.ports.list()` plus
`space`, `space_name` and `awake` (running or starting: billed now). `state`: one state or a list of them.

```python
for p in shark.ports.list(state=["running", "starting"]):
    if p["last_request_at"] is None:
        shark.spaces.get(p["space"]).ports.sleep(p["port"])
```

## Secrets

**Beta.** Values are write-only: no call returns one. Default exposure is `inject`: commands see a placeholder
`shk_ph_…` and the node's egress proxy puts the real value only into requests to the secret's `hosts`; **the value
never enters the VM**. `exec=True` puts the value in the environment of commands that list it in `secrets=[...]`
(the response warns `secret_exposed_to_code`). `in_`: where the value goes: `"bearer"` (default), `"basic"`,
`"header:NAME"`, `"query:PARAM"`. `preset`: `"anthropic"`, `"github"`, `"npm"`, `"openai"` fill `hosts` and `in_`
([concepts.md](concepts.md#secrets-inject-default-vs-exec)). Writing secrets needs `secrets:write`.

`space.secrets` holds the Space's own secrets and references to account secrets; `shark.secrets` holds the
account's (set once; `apply="all"` reaches every Space whose `account_secrets` takes it, `apply="ref"` only Spaces
that reference it). A Space secret wins over an account secret of the same name. Inconsistent arguments (an `exec`
secret with `hosts`, an `inject` secret without `hosts` or `preset`) raise `ValueError` before any request.

### `space.secrets.set`

```python
space.secrets.set(name, value, *, preset=None, hosts=None, in_=None, exec=False, allow_egress=False) -> dict
```

Stores a Space secret; returns `{"names", "warnings"}` (e.g. `scope_host_not_allowed`). `allow_egress=True` also
adds the hosts to the Space's egress allowlist.

```python
space.secrets.set("STRIPE_KEY", key, hosts=["api.stripe.com"])
space.secrets.set("GITHUB_TOKEN", tok, preset="github", allow_egress=True)    # bring your own GitHub token
space.secrets.set("DB_URL", url, exec=True)
space.run("python migrate.py", secrets=["DB_URL"])
```

### `space.secrets.reference`

```python
space.secrets.reference(name) -> dict
```

Uses the account secret of this name in this Space (how an sdk Space with `account_secrets="none"` takes one). A name
with no account secret is accepted with the warning `account_secret_not_found`.

### `shark.secrets.set`

```python
shark.secrets.set(name, value, *, preset=None, hosts=None, in_=None, exec=False, apply=None) -> dict
```

Stores an account secret (`PUT /secrets`). `apply`: `"all"` (default) or `"ref"`.

```python
shark.secrets.set("OPENAI_API_KEY", key, preset="openai", apply="ref")
```

The methods below exist on both `space.secrets` and `shark.secrets`.

### `secrets.presets`

```python
secrets.presets() -> List[dict]
```

The provider catalog: `name`, `suggested_name`, `hosts`, `in`, `note`.

### `secrets.list`

```python
secrets.list() -> List[dict]
```

Names, `expose`, `hosts`, `in`, `source` (`space` or `account`), `shadowed`, `missing`, `excluded`, `apply`: never
values.

### `secrets.get`

```python
secrets.get(name) -> dict
```

One entry of `list()` (the one in effect). Raises `NotFoundError`.

### `secrets.rotate`

```python
secrets.rotate(name, value) -> dict
```

A new value; exposure, hosts and locations stay. On `space.secrets`, a name that is the account's raises
`ValueError` (rotate it with `shark.secrets.rotate`).

### `secrets.edit`

```python
secrets.edit(name, *, preset=None, hosts=None, in_=None, exec=None, apply=None) -> dict
```

Changes hosts, locations, exposure or `apply` and keeps the stored value.

### `secrets.remove`

```python
secrets.remove(name) -> None
```

Removing a name that is not there is not an error.

### `secrets.usage`

```python
secrets.usage(name) -> dict
```

Per host (and, for an account secret, per Space): `count` (requests that got the value), `blocked` (requests refused
for carrying its placeholder elsewhere), `last_used`.

### `secrets.status`

```python
secrets.status(agents=None, presets=None) -> dict
```

What is missing and the fix, computed by the node (`GET /spaces/{id}/secrets/status`, `GET /secrets/status`; the
`shark secrets status --json` document): `scope`, `space`, `agents`, `presets`, `egress`, `secrets` (each with
`active`, `shadowed`, `missing`, `egress_blocked`, `usage`), `gaps` (`code`, `severity`, `secret`, `agent`, `hosts`,
`message`, `fix`) and `ok` (no gap of severity `error`). Gap codes: `missing_secret`, `egress_not_allowed`,
`account_secret_unset` (errors), `shadowed`, `placeholder_blocked`, `egress_unknown` (info). `agents`: `claude`,
`codex`, `opencode` (on a Space, the Space's own agent when none is given); `presets`: preset names. Never a value.

```python
st = space.secrets.status(agents=["claude"])
for g in st["gaps"]:
    print(g["severity"], g["message"], "->", g["fix"])
```

### `shark.space_defaults.get`

```python
shark.space_defaults.get() -> dict
```

The account's defaults for Spaces that do not set `account_secrets` / `github`, and `effective` (what an sdk and a
devbox Space get).

### `shark.space_defaults.set`

```python
shark.space_defaults.set(*, account_secrets=UNSET, github=UNSET) -> dict
```

`account_secrets`: `"all"`, `"none"` or `None` (back to the profile's). `github`: `True`, `False` or `None`. Needs
`secrets:write`.

## Egress

**Beta.** Per-Space outbound policy for the Space's commands and its agent's session VM: **profiles** (Shark's named,
versioned host lists: `shark.egress.profiles()`) + the Space's own `allow` hosts − its `deny` hosts (deny wins). Modes
`allowlist` (default), `deny`, `open` (only accounts allowed it: `PermissionDeniedError` `plan_restriction`). Private
and link-local ranges are always refused. Defaults: SDK Spaces start with the `coding` profile (package registries,
GitHub, Ubuntu mirrors: the base every Space had), devbox Spaces with `essentials` (plus GitLab and Docker Hub). At
create: `egress={"profiles": [...], "allow": [...], "deny": [...]}`, or a name (`"deny"`, `"open"`, `"coding"`, any
profile). An agent start adds its harness's profile by itself; `space.secrets.set(…, preset=P, allow_egress=True)`
adds P's profile. Applies from the next command; forks inherit it. Writes need `network:write`; a refused entry is
`InvalidRequestError` with `details` `field` / `host` / `profile` / `reason` (`unknown_profile`, `included_by`,
`bad_name`, …). Details: [api.md](api.md#secrets-and-egress), [concepts.md](concepts.md#egress-policy).

```python
sp = shark.spaces.create(egress={"profiles": ["coding", "anthropic-api"], "allow": ["db.example.com:5432"]})
sp.egress.use("+openrouter", "-pypi")                      # one atomic change; -pypi also takes it out of coding
for e in sp.egress.get()["effective"]["entries"]:
    print(e["host"], e["from"])                            # e.g. pypi.org ['coding/pypi'], db.example.com:5432 ['space']
for d in sp.egress.denials(since="1h"):
    print(d["host"], d["count"], d["suggest"])             # what was refused, and the profile that has it
```

### `shark.egress.profiles`

```python
shark.egress.profiles() -> List[dict]
```

Every profile: `name`, `title`, `kind`, `about`, `includes`, `hosts` (`host`, `why`, `verified`: `binary:<pkg>@<version>`,
`probe`, `docs:…`, `legacy`), `all_hosts`, `source`, `checked`, `not_included` (left out on purpose), `harness`.

### `space.egress.get`

```python
space.egress.get() -> dict
```

The policy: `mode`, `profiles`, `allow`, `deny`, `auto` (profiles Shark added by itself), `auto_profiles`, `base` (old
field), `source` (`space` | `default` | `host`), `profiles_version`, `effective` (`hosts`, `entries` with `from`,
`denied`).

### `space.egress.set`

```python
space.egress.set(*, mode=None, profiles=None, allow=None, deny=None) -> dict
```

Replaces the policy (`PUT`); a field left `None` keeps its current value.

### `space.egress.use`

```python
space.egress.use(*changes) -> dict
```

`"+name"` adds a profile, `"-name"` removes one (also out of a composite: `"-npm"` under `essentials`; `"+open"` /
`"-open"` switch mode open). Removing a part of a harness profile is `InvalidRequestError` `included_by`: deny its
hosts instead.

### `space.egress.allow`

```python
space.egress.allow(*hosts) -> dict
```

Adds hosts of the Space's own (`example.com`, `*.example.com`, `db.example.com:5432`, `203.0.113.0/24:5432`).

### `space.egress.disallow`

```python
space.egress.disallow(*hosts) -> dict
```

Removes hosts of the Space's own; one that is not listed comes back in `not_listed`.

### `space.egress.deny`

```python
space.egress.deny(*hosts) -> dict
```

Refuses hosts (`name` or `*.suffix`, every port) even when a profile or an allow entry has them.

### `space.egress.undeny`

```python
space.egress.undeny(*hosts) -> dict
```

Drops deny entries; one that is not listed comes back in `not_listed`.

### `space.egress.denials`

```python
space.egress.denials(since="24h") -> List[dict]
```

Hosts the egress proxy refused (`host`, `port`, `count`, `first`, `last`, `kinds`, `suggest`: the profiles that have
it, `add`: the CLI command that allows it). Host names and ports only, never content. `since`: `"1h"`, `"30m"`,
`"2d"` or an RFC 3339 time.

## GitHub

Two ways to give a Space GitHub access:

- **Your account's connection** (a person connects once with `shark github connect`): `github=True` on the Space
  ([cli.md](cli.md#github)).
- **Your own GitHub App** (a platform whose users install *your* App; control-plane mode only, otherwise
  `SharkError(code="needs_control_plane")`): register it once, then link each Space to one installation.

### `shark.github_app.register`

```python
shark.github_app.register(app_id, private_key) -> dict
```

`private_key`: the App's PEM (`str` or `bytes`), sealed by the account service and never returned. Returns
`{registered, app_id, slug, verified}`. Needs `secrets:write`; a pair GitHub refuses is `github_app_refused`.

### `shark.github_app.status`

```python
shark.github_app.status() -> dict
```

`{registered, app_id, slug, linked_spaces}`, never the key.

### `shark.github_app.remove`

```python
shark.github_app.remove() -> dict
```

Forgets the App and every Space link to it.

### `shark.github_app.link`

```python
shark.github_app.link(space, installation_id, repos=None) -> dict
```

Stores a token of that installation (narrowed to `repos`) as the Space's `GITHUB_TOKEN` (github preset, inject); the
Space's node keeps it fresh. Returns the metadata and `"secret": "GITHUB_TOKEN"`, never the token. Errors
(`code`): `github_app_not_registered`, `github_not_connected` (not an installation of your App), `github_no_access`
(a repository outside it).

```python
shark.github_app.register(123456, open("app.pem").read())
shark.github_app.link(space, installation_id=7890, repos=["site"])
space.run("git clone https://github.com/acme/site && cd site && git push origin HEAD:try-1")
```

## Usage, quotas and balance

Usage is counts and durations only (never command text, paths or contents). Live caps: `shark.limits()`. What is
metered: [billing.md](billing.md).

### `shark.usage.get`

```python
shark.usage.get(start=None, end=None, group_by="day") -> dict
```

**Beta.** Rows of `commands`, `command_s`, `command_vcpu_s`, `service_awake_s`, `process_awake_s`,
`storage_gib_h`, `egress_bytes`; `group_by`: `"day"`, `"space"` or `"kind"`; `start` / `end`: ISO dates.
Needs `usage:read`.

### `shark.usage.balance`

```python
shark.usage.balance() -> dict
```

The prepaid balance in micro-units: `balance_micro`, `paid_micro`, `promo_micro` (control-plane mode; direct mode
raises `SharkError(code="config")`).

## API keys

**Beta.** Need the `admin` scope.

### `shark.keys.list`

```python
shark.keys.list() -> List[dict]
```

Never the secret.

### `shark.keys.create`

```python
shark.keys.create(name="", scopes=None, idempotency_key=None) -> dict
```

The new key's secret is in this response only: store it in your secret manager, never in code or a Space.

### `shark.keys.rotate`

```python
shark.keys.rotate(key_id, overlap_s=86400) -> dict
```

A new secret; the old one keeps working for `overlap_s` seconds.

### `shark.keys.revoke`

```python
shark.keys.revoke(key_id) -> None
```

## Async client

`AsyncShark` has the same attributes and method names as `Shark`, as awaitables: each call runs the synchronous
client in a thread pool, so a fan-out over many Spaces sends that many requests at once (the server's caps, `429`
with `Retry-After`, bound the real concurrency). `stream()`, `events()` and `watch()` return the same blocking
iterators as the sync client: iterate them in a thread, or use `run(..., on_stdout=…)`.

### `AsyncShark`

```python
AsyncShark(*a, max_concurrency=64, **kw)
```

Takes `Shark`'s arguments plus `max_concurrency` (threads). Attributes: `spaces`, `secrets`, `space_defaults`,
`github_app`, `keys`, `usage`. Usable as `async with AsyncShark() as shark:`.

```python
import asyncio
from shark import AsyncShark

async def main():
    async with AsyncShark() as shark:
        base = await shark.spaces.create(name="base")
        forks = await base.fork(count=8)
        results = await asyncio.gather(*(f.run(f"python solve.py --seed {i}") for i, f in enumerate(forks)))
        print([r.exit for r in results])

asyncio.run(main())
```

### `ashark.me`

```python
ashark.me() -> dict
```

### `ashark.capabilities`

```python
ashark.capabilities() -> dict
```

### `ashark.limits`

```python
ashark.limits() -> List[dict]
```

### `ashark.aclose`

```python
ashark.aclose() -> None
```

Shuts the thread pool down.

### `ashark.transport`

```python
ashark.transport -> Transport
```

## Framework integrations

Optional extras (`shark-sdk[langchain]`, `[langgraph]`, `[smolagents]`, `[prefect]`) under `shark.integrations`:
`SharkExecTool`, `SharkReadFileTool`, `SharkWriteFileTool`, `shark_tools` (LangChain); `thread_space`,
`branch_spaces`, `thread_id_of`, `task_key` (LangGraph); `SharkExecutor`, `SharkCodeAgent` (smolagents); `task_fork`,
`task_keys`, `task_op_id` (Prefect); and the shared helpers `op_id_for`, `space_for`, `forked`, `fork_many`,
`format_result`, `label_value`. Usage and what was tested: [integrations.md](integrations.md).

## Not in the SDK yet

Services (`.shark/services.toml` instead), snapshots, webhooks and events, Space-scoped tokens and sub-accounts answer
`422 unsupported` on the node ([api.md](api.md#coming-answer-422-unsupported-today)); the terminal is coming. Durable replication status
(`GET /spaces/{id}/durable`) has no method: `space.settings()["durable"]`.