sharkmagic.docs
Python SDK

Agent? Fetch sharkmagic.si/docs/llms.txt or this page as markdown

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. The HTTP routes behind each call: api.md.

Status follows 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#

<PY_SDK_INSTALL>

The package is not on PyPI yet; the placeholder is explained in quickstart.md. It imports as shark.

Authentication#

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

export SHARK_API_KEY=...        # never in code, in a file of the Space, or in an agent's prompt
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; 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
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#

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#

shark.me() -> dict

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

shark.capabilities#

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#

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

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

shark.close#

shark.close() -> None

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

shark.transport#

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#

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

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

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

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.

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#

shark.spaces.get(id_or_name) -> Space

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

shark.spaces.list#

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.

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

space.id#

space.id -> str

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

space.state#

space.state -> str

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

space.labels#

space.labels -> Dict[str, str]

space.profile#

space.profile -> str

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

space.refresh#

space.refresh() -> Space

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

space.settings#

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

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

space.update#

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

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

space.fork#

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.

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

space.hibernate#

space.hibernate() -> Space

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

space.wake#

space.wake() -> dict

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

space.checkpoint#

space.checkpoint() -> dict

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

space.delete#

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#

space.audit() -> dict

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

space.usage#

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

This Space's metered usage (beta; GET /spaces/{id}/usage?from&to); see usage.

Commands#

Three ways to run a command, all exactly once per op_id (concepts.md): 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#

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, …).

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#

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.

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#

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

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

space.op#

space.op(op_id) -> Op

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

space.ops#

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

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

op.state#

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#

op.refresh() -> Op

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

op.events#

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#

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.

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#

op.output() -> str

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

op.signal#

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

event.id -> Optional[str]

The event's sequence number (resume point).

event.type#

event.type -> str

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

event.data#

event.data -> dict

The event's JSON.

event.text#

event.text -> str

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

event.bytes#

event.bytes -> bytes

The same chunk as bytes.

event.exit#

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#

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

Raises NotFoundError.

space.files.read_text#

space.files.read_text(path, at=None, encoding="utf-8") -> str
print(space.files.read_text("out/report.json"))

space.files.write#

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#

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

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

space.files.exists#

space.files.exists(path) -> bool

space.files.list#

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#

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

space.files.move#

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

space.files.upload_dir#

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.

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

space.files.download_dir#

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#

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

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

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

space.processes.get#

space.processes.get(pid) -> dict

space.processes.logs#

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#

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

Stops it through its keeper, so its writes land.

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

space.ports.declare#

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

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#

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#

space.ports.get(port) -> dict

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

space.ports.set_public#

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#

space.ports.sleep(port) -> dict

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

space.ports.logs#

space.ports.logs(port) -> str

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

space.ports.remove#

space.ports.remove(port) -> None

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

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#

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.

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

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.

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#

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#

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

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

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

secrets.presets#

secrets.presets() -> List[dict]

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

secrets.list#

secrets.list() -> List[dict]

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

secrets.get#

secrets.get(name) -> dict

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

secrets.rotate#

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#

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#

secrets.remove(name) -> None

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

secrets.usage#

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#

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.

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

shark.space_defaults.get#

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#

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, concepts.md.

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#

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#

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#

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#

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#

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#

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#

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#

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

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

space.egress.denials#

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

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#

shark.github_app.status() -> dict

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

shark.github_app.remove#

shark.github_app.remove() -> dict

Forgets the App and every Space link to it.

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

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.

shark.usage.get#

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#

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#

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

Never the secret.

shark.keys.create#

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#

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

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

shark.keys.revoke#

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#

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

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#

ashark.me() -> dict

ashark.capabilities#

ashark.capabilities() -> dict

ashark.limits#

ashark.limits() -> List[dict]

ashark.aclose#

ashark.aclose() -> None

Shuts the thread pool down.

ashark.transport#

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.

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); the terminal is coming. Durable replication status (GET /spaces/{id}/durable) has no method: space.settings()["durable"].