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#
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=Trueon 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#
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"].
