Agent? Fetch sharkmagic.si/docs/llms.txt or this page as markdown
HTTP API v1#
Two hosts. The node serves Spaces, commands and files: https://hel1.sharkmagic.si/v1 (the hosted node). The account service, https://api.sharkmagic.si/v1, holds accounts, keys, balance and placement
(below).
Auth. Every node request except healthz and capabilities needs Authorization: Bearer <node token>. With an
account API key (shk_live_…), get the token from the account service: POST https://api.sharkmagic.si/v1/token
(the key as Bearer; body {}, or {"space": "<id>"} for the node that holds that Space) →
{"token", "expires_in": 300, "tenant", "scopes", "node": {"id", "url"}}. Send the call to node.url; mint a new
token before it expires. The key itself never goes to a node. The CLI and the SDKs do this for you
(quickstart.md). A self-hosted node in direct mode takes its own
keys as the Bearer instead.
JSON in, JSON out, unless a route says otherwise. A machine-readable OpenAPI 3.1 contract is published alongside these docs at launch; where it and this page differ, this page follows the running server.
Status on every route: GA-path (served and tested), beta (served; local-only tests or a gap noted), coming
(answers 422 unsupported today). Errors: errors.md. Limits: limits.md.
Conventions#
- Auth and tenancy. The account comes from the key; a body never chooses it. Another account's Space answers
404. Scopes per route are in brackets below. - Ids or names.
{id}accepts a Space id (tti-<32 hex>) or its name. - Idempotency. Commands take an
op_id(body, or headerShark-Op-Idfor file writes); create and fork take anIdempotency-Keyheader. A retry with the same id replays the stored response ("replayed": true, headerShark-Replayed: true); the same id with a different request is409 idempotency_mismatch. - Headers on every response:
Shark-Request-Id. Retryable429/503carryRetry-After. - Pagination:
?limit=(default 50, max 1,000) and?cursor=; responses are{"data": [...], "next_cursor": "…" | null}. - Streams are Server-Sent Events: each event has an
id:(a sequence number) and a JSONdata:. Reconnect withLast-Event-IDto resume; nothing is lost or repeated. While a command is silent the server sendsevent: pingevery 15 s. - Additive versioning. Within v1 we only add endpoints, optional fields, response fields and enum values. Ignore fields you do not know.
System and identity#
| route | scope | status | what |
|---|---|---|---|
GET /healthz |
none | GA-path | {"ok": true} |
GET /capabilities |
none | GA-path | API version, features on this node, limits, lease shapes |
GET /me |
any | GA-path | your account (tenant), plan, and the credential's id, kind, scopes and expiry |
GET /quotas |
usage:read |
GA-path | every limit that applies to you, with current use: {"limits_class", "raise", "data":[{"name","scope","limit","used"}]}; limits_class is your account's class (new, standard, partner, …) and raise how to get more; per-label limits are rows with scope: label, selector, label (and values for a key selector) (limits.md). Account caps are scope: tenant rows: spaces, awake_spaces (Spaces awake at once) and awake_services (exposed processes running or starting at once; a start or wake past it is refused) |
GET /keys, POST /keys, POST /keys/{key_id}/rotate, DELETE /keys/{key_id} |
admin |
beta | list (never the secret), create (the secret is in this response only), rotate with overlap, revoke |
Spaces#
| route | scope | status | what |
|---|---|---|---|
POST /spaces |
spaces:write |
GA-path | create. Body (all optional): name, labels (map), env (non-secret defaults for commands), egress (policy, below, or "deny"/"coding"/"open"), profile and the account fields (account_secrets, github, git, keep_background, keep_awake, space_cli: SDK Spaces and devbox Spaces), from ({"space": id, "at": commit}: start from another Space's content, O(1)), restore (bring a Space back from the durable store under its own id). services at create and from.snapshot are coming (422). Header Idempotency-Key. 201 with the Space |
GET /spaces |
spaces:read |
GA-path | list, newest first; filters label=k:v (repeatable), state, name. Does not wake hibernated Spaces |
GET /spaces/{id} |
spaces:read |
GA-path | one Space with settings: every per-Space setting in one call (below); does not wake it |
PATCH /spaces/{id} |
spaces:write (+ network:write for egress, exec for ports, keep_background, keep_awake, space_cli; secrets:write for account_secrets, github, git) |
GA-path (egress, ports: beta) |
change name, labels (replace), env (replace), dormancy, egress (a whole policy, or {"add","remove","clear","mode","base"}), ports ({"3000": {"public": false}}), account_secrets, github, git, keep_background, keep_awake, space_cli in one request. Every field is validated before any is applied: a refused field changes nothing. Returns the Space with settings |
DELETE /spaces/{id} |
spaces:write |
GA-path | waits for the running command, tears down; ?purge=true also deletes the durable copy |
POST /spaces/{id}/fork |
spaces:write |
GA-path | {"count": 1..50, "labels", "inherit_labels": true, "name_prefix", "at": commit} plus any of account_secrets, github, git, keep_background, keep_awake, space_cli (unset: the source's; the profile is the source's). Header Idempotency-Key. 201 {"spaces": [...]}, each with fork_of and fork: {from, at, objects_linked, bytes_copied: 0, ms}. Files O(1); env and egress copied; the Space's own secrets, processes and service memory not (account secrets reach the fork as any Space of the account). "services": "copy" is coming (422) |
POST /spaces/{id}/hibernate |
spaces:write |
GA-path | hibernate now. The Space's idle host-side helpers (its file mount, its service host) are stopped first and come back on the next use; 409 conflict if something runs (a command, a kept job or service, a terminal, an agent session, unsaved file changes), with the reason in details.why. A file mount left without its control socket is recovered first (its unsaved writes land in the Space); if that fails, details.why says so and what to do |
POST /spaces/{id}/wake |
spaces:write |
GA-path | wake now; returns wake_ms. Any other request wakes it implicitly. POST /spaces/{id}/up is a deprecated alias |
GET /spaces/{id}/audit |
spaces:read |
GA-path | commits from HEAD with their op_id, changed paths and exit |
POST /spaces/{id}/checkpoint |
spaces:write |
GA-path | returns once HEAD and its objects are in the durable store; 503 durable_unavailable otherwise |
GET /spaces/{id}/durable |
spaces:read |
GA-path | replication status: commit, behind, rpo_ms, fenced |
A Space:
{"id": "tti-14bc859988847391c36a5d6bf889b300", "name": "curl-demo", "tenant": "<your-account>", "state": "ready",
"labels": {}, "head": "d656da49…", "created_at": "2026-11-05T07:11:09.36Z", "last_activity_at": "2026-11-05T07:11:09.37Z",
"durable": {"commit": "", "behind": false, "rpo_ms": 0, "fenced": false}, "session": false, "error": null}
state is ready or hibernated; fork_of appears on forks; profile is sdk or devbox; "keep_awake": true
appears when the Space's jobs never sleep (billed awake).
settings (on GET /spaces/{id} and PATCH, not on the list):
| key | what | change it with |
|---|---|---|
name, labels |
as on the Space | PATCH /spaces/{id} |
env |
non-secret defaults put in every command's environment (≤ 128); a command's own env wins on the same name |
PATCH /spaces/{id} |
egress |
mode, profiles, allow, deny, auto, base, source, and effective (hosts, entries with provenance, denied, base_hosts: from profiles, space_hosts) |
PUT/PATCH /spaces/{id}/egress, or egress in PATCH /spaces/{id} |
dormancy |
idle_s (the Space's own: 0 = never), source (space or node), node_idle_s, effective_idle_s (null = never hibernates by itself) |
dormancy in PATCH/POST /spaces: {"idle_s": N} (60 s – 30 d, 0 = never), "off", "default" |
secrets |
available (the node has a sealed store), secrets: name, expose, hosts, never a value |
PUT /spaces/{id}/secrets, DELETE …/secrets/{name} |
ports |
available (exposed processes on this node), public_allowed, ports (as GET …/ports) |
PUT/PATCH/DELETE …/ports/{port}, or ports in PATCH /spaces/{id} |
durable |
enabled, commit, behind, rpo_ms, fenced |
POST …/checkpoint |
agent |
the coding agent's state, or null (read-only: started by shark code or POST …/agent/runs) |
POST …/agent/stop |
profile, account_secrets, github, git, keep_background, keep_awake, space_cli, harness |
each {value, source} (harness: space, account, none, or profile on an sdk Space) (keep_background and space_cli also node_supports; space_cli its scopes) |
PATCH /spaces/{id} |
limits |
lease shape, sync/stream timeout caps, fork count, label/env/egress caps (node settings, read-only) | — |
created_by, fork_of |
who made it; the source of a fork | — |
The GitHub repository link of a Space lives in the account service: GET/DELETE /v1/spaces/{id}/github there
(shark github link|unlink).
SDK Spaces and devbox Spaces (profile)#
Each Space has a profile, set when it is created and never inferred from the client. shark create and
shark code make devbox Spaces (one developer: the account's secrets and GitHub in every Space). POST /spaces
without a profile (the SDKs, a platform whose own users run in Spaces) makes sdk Spaces: nothing of the
account reaches them unless the Space asks. Spaces made before profiles are devbox. Full table and tests:
docs/sdk-vs-devbox-2026-11-07.md in the repository.
field (create, PATCH, fork) |
devbox default | sdk default | values |
|---|---|---|---|
profile |
— | sdk |
"sdk" | "devbox"; create only, a fork keeps its source's |
account_secrets |
"all" |
"none" |
"all" (the account's apply: all secrets), "none" (only the ones the Space references itself with {"ref": "account"}), or a list of names |
github |
on | off | true / false (also "account" / "off"): the account's GitHub connection (managed GITHUB_TOKEN; the node does not mint it for a Space that has it off) |
git |
the connection's | none | {"name", "email"}: the Space's commit identity (lowest precedence: the Space's ~/.gitconfig wins) |
keep_background |
true |
false |
a process a command leaves running (cmd &) stays as a job bg-N; false: it ends with the command. Per command: "background": true|false in exec |
keep_awake |
false |
false |
true: the Space's kept jobs never pause or snapshot when idle (billed awake; "keep_awake": true on the Space). Default: they sleep when idle and wake on a connection |
space_cli |
true |
false |
the Space credential (shk_agt_) an agent in the Space gets for the in-Space shark CLI, with egress to the node's API host: true, false or {"scopes": [...]} (subset of spaces:read, files:read, exec, usage:read) |
egress |
node default (essentials) |
node default (coding) |
a policy object ({"profiles": [...], "allow": [...], "deny": [...]}), or a name: "deny" (no egress), "open", or any profile ("coding", "essentials", "opencode", …) |
labels |
— | — | {"k": "v"} at create and PATCH; GET /spaces?label=k:v filters |
harness |
none | never | {"agent": "claude"|"codex"|"opencode", "auth": "subscription"|"api-key"} or null: the coding agent shark code starts without asking (shark-code.md); refused on an sdk Space (details.reason: sdk_profile), for an agent the node lacks (unknown_agent) or a login it lacks (auth_unsupported) |
null in PATCH sets a field back to the profile's default. A fork takes its source's values unless the fork
request sets them. Changing account_secrets or github needs secrets:write; keep_background, keep_awake and
space_cli need exec. A refused value is 400 invalid_request with details.field. settings shows each value
with its source (space, account, profile, default). GET /secrets of a Space lists the account secrets its
selection keeps out with excluded: true (never a value).
GET /space-defaults [spaces:read] and PUT /space-defaults [spaces:write; account_secrets and github also
secrets:write] {"account_secrets": "all"|"none"|null, "github": "account"|"off"|null, "harness": {"agent", "auth"}|null}:
the account's defaults for every Space that does not set the field (then the profile's; harness reaches devbox Spaces
only). The response's effective says what an sdk and a devbox Space get.
GET /agents [any key]: the coding agents (harnesses) this node's image runs, from its catalog (limited by the node's
-agent-harnesses): {"data": [{"name", "title", "description", "auth": ["subscription", "api-key"], "keys": [{"env", "host", "preset"}], "resume"}], "source": "node"}. GET /capabilities lists their names in features.agents.
A bring-your-own GitHub token per Space needs nothing special: PUT /spaces/{id}/secrets with the github preset
(GITHUB_TOKEN, hosts github.com, api.github.com); git and gh use it like the managed one.
Commands (ops)#
POST /spaces/{id}/exec [exec] — GA-path#
{"cmd": "npm ci && npm test", "op_id": "toolu_01AbC", "timeout_s": 900, "cwd": "web", "env": {"CI": "1"}}
| field | meaning |
|---|---|
cmd (required) |
a bash command line, 1 B – 256 KiB; runs in a fresh microVM with the Space mounted |
op_id |
1–64 of [A-Za-z0-9_.:-]; generated (op_…) if absent. Use the tool-call id |
timeout_s |
sync: default 120, max 900; stream or async: default 1,800, max 7,200. Above the max: 400 with details.max_s (never a silent cap). The applied value comes back as timeout_s |
cwd |
relative to the Space root (default: the root) |
env |
non-secret variables for this command (≤ 128) |
secrets |
names of the Space's exec-exposed secrets to put in this command's environment (secrets) |
stdin, stdin_encoding |
up to 1 MiB, text or base64 |
wait: false |
async: 202 at once with the op and its events_url |
background |
part of the request identity; whether processes outlive the command is a node setting; the hosted node keeps them (concepts.md) |
max_output |
bytes of each stream returned by sync mode (≤ 1 MiB) |
stdin_stream |
coming (422) |
Three modes, chosen by the request:
- sync (default): waits;
200with the result below. - stream:
Accept: text/event-stream; eventsstart,stdout,stderr({"data", "encoding": "utf8"|"base64"}),detached, thenexit(the result) orerror. - async:
"wait": false;202with the op (state: running,events_url).
Result (sync, real response from a test server; timings there are not VM timings):
{"op_id": "qs-1", "exit": 0, "shark_status": "ok", "stdout": "hello\n", "stderr": "",
"stdout_truncated": false, "stderr_truncated": false, "truncated": false, "replayed": false,
"commit": null, "changed_paths": 0, "run_s": 0.0139, "timeout_s": 120,
"lease": {"vcpu": 8, "mem_mib": 4096}, "wall_ms": 14.4, "queue_ms": 0.39, "shim_ms": 13.9, "wake_ms": null}
A non-zero exit is a normal 200. shark_status (ok, conflict, timeout, infra_timeout, infra_error,
capacity_unavailable, signaled) says whether an unusual outcome came from Shark: concepts.md.
commit is null when the command changed no file. wake_ms is set when the request woke the Space.
Ops#
| route | scope | status | what |
|---|---|---|---|
GET /spaces/{id}/ops |
spaces:read |
GA-path | recent ops, newest first; `?state=running |
GET /spaces/{id}/ops/{op_id} |
spaces:read |
GA-path | the op: state, cmd (to its own account), result |
GET /spaces/{id}/ops/{op_id}/events |
spaces:read |
GA-path | SSE of the op from Last-Event-ID (or the start), live or after it ended, for 7 days |
POST /spaces/{id}/ops/{op_id}/signal |
exec |
GA-path | `{"signal": "TERM" |
POST /spaces/{id}/ops/{op_id}/stdin |
exec |
coming | 422 |
Background processes#
| route | scope | status | what |
|---|---|---|---|
GET /spaces/{id}/processes |
spaces:read |
beta | jobs of the Space: id (bg-1), op_id, cmd (the command, secret-shaped values redacted and cut at 256 bytes, as op logs show it; null when unknown), state (running, paused, snapshotted, exited), ports, exit |
GET /spaces/{id}/processes/{pid} |
spaces:read |
beta | one |
GET /spaces/{id}/processes/{pid}/logs |
spaces:read |
beta | its output; ?tail_bytes= |
POST /spaces/{id}/processes/{pid}/signal |
exec |
beta | stops it ({"signal": "TERM"}; the job ends through its keeper, so its writes land) |
The agent's Space credential under shark code (shk_agt_…) may call all four for its own Space (shark ps,
shark logs bg-N, shark kill bg-N). Logs are the committed .shark/bg/<op>.log (header Shark-Granularity: commit).
On the hosted node a process a command leaves running (cmd &, nohup) is kept as one of these jobs; what that means
inside a Space today: concepts.md.
Files#
Reads are served from the committed tree without starting a VM (and without waking a hibernated Space). Writes are
ops: one commit each, stamped with Shark-Op-Id (header; generated if absent), merged like commands, so a write that
races a command on the same path is 409 conflict with details.paths, never a lost update. Paths are relative to the
Space root.
| route | scope | status | what |
|---|---|---|---|
GET /spaces/{id}/files?path=P[&at=commit] |
files:read |
GA-path | file bytes; ETag, Range, Shark-Commit |
HEAD /spaces/{id}/files?path=P |
files:read |
GA-path | metadata only |
PUT /spaces/{id}/files?path=P[&mode=0644] |
files:write |
GA-path | upload one file (streamed body, ≤ 512 MiB); parents created; If-Match: <etag> for compare-and-swap (412) |
DELETE /spaces/{id}/files?path=P[&recursive=true] |
files:write |
GA-path | remove (one commit) |
POST /spaces/{id}/files/move |
files:write |
GA-path | {"from", "to", "overwrite"} |
GET /spaces/{id}/tree?path=P[&depth=N][&glob=G][&at=commit] |
files:read |
GA-path | listing: path, type, size, mode, mtime, hash; paginated |
POST /spaces/{id}/files/archive?path=DIR[&replace=true] |
files:write |
GA-path | upload a tar (plain, gzip or zstd) into DIR as one commit (≤ 2 GiB) |
GET /spaces/{id}/files/archive?path=DIR[&at=commit] |
files:read |
GA-path | download DIR as a tar |
GET /spaces/{id}/changes[?path=prefix] |
files:read |
GA-path | SSE, one event per landed commit (commit, op_id, paths), from Last-Event-ID (a commit) or now |
Secrets and egress#
| route | scope | status | what |
|---|---|---|---|
PUT /spaces/{id}/secrets |
secrets:write |
beta | set or delete (null or "") secrets, or reference an account secret ({"NAME": {"ref": "account"}}). Values sealed on the node, never returned |
GET /spaces/{id}/secrets |
spaces:read |
beta | what the next command gets: name, expose, hosts, in, updated_at, source (space | account); account secrets this Space overrides come with shadowed: true, references to no account secret with missing: true; names = the names commands get. Never values |
DELETE /spaces/{id}/secrets/{name} |
secrets:write |
beta | delete one |
GET /spaces/{id}/secrets/{name}/usage |
spaces:read |
beta | per host: count (requests that got the value), blocked (refused for carrying its placeholder out of scope), last_used |
PUT /secrets |
secrets:write |
beta | account secrets: same body as a Space's, plus apply: all (default: every Space of the account, new Spaces and forks included) or ref (only Spaces that reference it). Not with a Space credential |
GET /secrets |
spaces:read |
beta | the account's secrets: name, expose, hosts, in, apply, updated_at; never values |
DELETE /secrets/{name} |
secrets:write |
beta | delete one; every Space's next command no longer gets it |
GET /secrets/{name}/usage |
spaces:read |
beta | per Space and host: count, blocked, last_used; totals count, blocked. available: false when the node keeps no usage log |
GET /spaces/{id}/secrets/status, GET /secrets/status ?agent=claude&preset=openai |
spaces:read |
beta | what is missing and the fix (the shark secrets status --json document): {scope, space, agents, presets, egress, secrets[{name, scope, expose, hosts, preset, apply, active, shadowed, missing, egress_blocked, usage}], gaps[{code, severity, secret, agent, hosts, message, fix}], ok}. Gap codes (stable): missing_secret, egress_not_allowed, account_secret_unset (severity error), shadowed, placeholder_blocked, egress_unknown (info); ok = no error. agent (repeated or comma list: claude, codex, opencode; on a Space, default the Space's own agent) and preset name what must be there; an unknown one is 400 invalid_request (details.field, details.reason: unknown_agent, unknown_preset). Never a value |
GET /secret-presets |
any | beta | the provider presets (name, suggested_name, hosts, in, note): anthropic, github, npm, openai |
GET /egress/profiles |
spaces:read |
beta | Shark's egress profiles: name, title, kind, about, includes, hosts (host, why, verified), all_hosts, source, checked, not_included, harness; version, aliases |
GET /spaces/{id}/egress |
spaces:read |
beta | the policy in force: mode, profiles, allow, deny, auto, auto_profiles, base (old field), source = space (set by API), default (the node's) or host, effective |
PUT /spaces/{id}/egress |
network:write |
beta | set the policy; applies from the next command (the agent's session VM: its next start); forks inherit it |
PATCH /spaces/{id}/egress |
network:write |
beta | {"use": ["+opencode", "-npm"], "add_profiles", "remove_profiles", "add": [...], "remove": [...], "add_deny", "remove_deny", "clear": true, "mode", "base", "auto_profiles"}: one atomic change (two clients editing at once both land). Removing something not listed is not an error: it comes back in not_listed |
GET /spaces/{id}/egress/denials?since=1h |
spaces:read |
beta | hosts the egress proxy refused for the Space: host, port (other than 80/443), count, first, last, kinds, suggest (profiles that have it), add (the command); never content. available: false when the node cannot read its proxy log |
PUT /v1/spaces/{id}/secrets
{"secrets": {
"GH_TOKEN": {"value": "…", "scope": {"hosts": ["api.github.com"]}},
"DB_URL": {"value": "…", "expose": "exec"}
}}
expose: "inject"(default) requiresscope.hosts: the command sees a placeholdershk_ph_…; the node's egress proxy swaps in the value only toward those hosts (default locationAuthorization: Bearer). The value never enters the VM.expose: "exec"puts the value in the environment of commands that list it ("secrets": ["DB_URL"]inexec). The response carrieswarnings: [{"code": "secret_exposed_to_code", …}].- A scope host the Space's egress does not allow is warned (
scope_host_not_allowed). "keep_value": trueinstead ofvaluechanges a stored secret'sexpose,scopeorapplyand keeps its value (shark secrets edit);400 invalid_requestwhen nothing is stored under the name, or with avaluetoo.
Account secrets (set once, used by every Space):
PUT /v1/secrets
{"secrets": {
"OPENAI_API_KEY": {"value": "…", "scope": {"hosts": ["api.openai.com"]}},
"GITHUB_TOKEN": {"value": "…", "scope": {"hosts": ["github.com", "api.github.com"], "in": [{"kind": "basic"}, {"kind": "bearer"}]}, "apply": "ref"}
}}
PUT /v1/spaces/{id}/secrets
{"secrets": {"GITHUB_TOKEN": {"ref": "account"}}}
- Resolution, at each command: the Space's own secrets, then the account's
apply: "all"secrets and the ones the Space references. A Space secret with the same name always wins. A fork gets the account's secrets (not the source Space's own secrets or references). - They reach the command exactly as a Space's do (
inject: a placeholder, the value only on the egress path to the scope;exec: only in commands that list the name). Sealed per account on the node, never returned. - A reference to an account secret that does not exist is accepted with the warning
account_secret_not_foundand gives commands nothing until it is set.
PUT /v1/spaces/{id}/egress
{"profiles": ["essentials", "opencode"], "allow": ["api.example.com", "*.example.com", "db.example.com:5432", "203.0.113.0/24:5432"],
"deny": ["*.githubusercontent.com"]}
profiles: names from GET /egress/profiles (aliases accepted; open = mode open, none = nothing). mode:
allowlist (implied when profiles or deny is given), deny, or open (only accounts allowed it; else
403 plan_restriction). The old "base": "coding" | "none" still works when profiles is absent. Up to 256 allow and
256 deny entries; bare IPs, private and link-local ranges are refused with 400 invalid_request (details.field =
allow[i], add[i], profiles[i], use[i], add_deny[i] or deny[i], details.host / details.profile,
details.reason: ip_without_port, private_range, cidr_without_port, bad_cidr, bad_port, bad_name,
invalid_host, too_many_entries, unknown_profile, included_by (removing a part of a harness profile: deny its
hosts instead), deny_needs_allowlist). GET returns effective: hosts, entries (host, from: the profiles
it comes from, "space" for yours), denied (host, from, by). An agent start adds its harness's profile
(listed in auto). Details: concepts.md.
Exposed processes, previews and the terminal#
Exposed processes and previews: GA-path, live on the hosted node. Each exposed process runs in its own microVM; the
whole flow (declare, private by default, one-time link, public toggle, logs, sleep after idle and wake on request,
remove) passed end to end on the hosted node (P130 flow PASS, 2026-11-07). The terminal route
(GET …/terminal, shark connect) is coming: tested locally only (P112), not yet verified on the hosted node. A node
without the exposed-process runner answers 422 unsupported ("exposed ports and terminals are off on this node") on
every route here; ports.available in GET /spaces/{id} says which. An exposed process is billed while awake, by
size; asleep it is not (billing.md).
| route | scope | what |
|---|---|---|
GET /ports?state=running,starting |
spaces:read |
every exposed process of the account on this node, awake first: {"data": [{space, space_name, port, state, awake, public, url, connections, last_request_at, awake_since, …}], "awake": N} (awake = running or starting: billed now). state: comma list of declared, starting, running, asleep, exited, failed (another word: 400 invalid_request). Not with a Space credential |
GET /spaces/{id}/ports |
spaces:read |
{"ports": [{port, state, url, cmd, cwd, idle_s, public, connections, starts, wakes, sleeps, last_request_at, awake_since, …}]}; last_request_at = the last request through the preview (null: none since the node started), awake_since = start of the current awake stretch (null unless running) |
PUT /spaces/{id}/ports/{port} |
exec |
declare {"cmd", "cwd", "env", "idle_s", "public"}: a server that starts on the first connection, sleeps after idle_s (default 30) and follows HEAD |
GET /spaces/{id}/ports/{port} |
spaces:read |
one |
PATCH /spaces/{id}/ports/{port} |
exec |
`{"public": true |
DELETE /spaces/{id}/ports/{port} |
exec |
stop it and revoke every preview cookie |
POST /spaces/{id}/ports/{port}/url |
exec |
{"next": "/path"} → {"url", "public", "login_url", "expires_at"}: a one-time link (60 s) that sets a cookie for that preview host only |
POST /spaces/{id}/ports/{port}/sleep |
exec |
put it to sleep now |
GET /spaces/{id}/ports/{port}/logs |
spaces:read |
its output |
GET /spaces/{id}/terminal |
exec |
WebSocket, subprotocol shark-pty.v1 (what shark connect uses): one op per line; cwd, exported variables and $? carry over; ≤ 8 per Space |
Preview URL: https://<space-id>-<port>.preview.sharkmagic.si.
GET /spaces/{id}/attach [exec] (beta) is the older terminal into a Space's session VM (Upgrade: shark-pty/1,
used by shark attach); it answers 409 when the Space has no session VM ("session": false on the Space).
Account service: tokens, workspace, orgs, keys, logins#
At https://api.sharkmagic.si/v1 (the account service, not a node). Every account has a personal workspace from
sign-up: keys, balance, plan and usage work without creating anything. Teams are opt-in. With your CLI login
(shark login's access token) or an API key:
| route | credential | what |
|---|---|---|
POST /token {space?, scopes?, ttl_s?} |
API key only | a node token (JWT, default 300 s) for the key's account and node {id, url}: the node that holds space, or where a new Space goes. scopes narrows the key's. At zero balance a token with write or exec scopes is refused (402 credits_exhausted); ask for ["spaces:read", "files:read", "usage:read"] to keep reading files |
GET /me |
login or key | account (tenant), organization (personal: true for the personal workspace), credential kind and scopes |
GET /placement?space=ID, POST /placement |
login or key (spaces:read / spaces:write) |
which node holds a Space / where a new one goes (the CLI's shark new) |
GET /orgs |
login; a key sees only its own org | {"data":[{"id","name","slug","role","tenant","personal","current"}]}, the personal workspace first |
POST /orgs {name, slug} |
login | create a team; you are its owner |
POST /orgs/{id|slug|personal}/use |
login | this login now acts in that org; refresh the access token (the CLI does) |
POST /orgs/{id|slug}/invitations {email, role} |
login of an owner/admin | invite someone into a team (role member or admin); 201 {id, email, role, status: "pending", expires_at} (48 h); an open invitation for the same address comes back with existing: true; 409 conflict if already a member. They accept in the dashboard |
GET /keys, POST /keys, POST /keys/{id}/rotate, DELETE /keys/{id} |
login of an owner/admin of the org, or a key with admin |
as the node routes above; 403 insufficient_role for a member |
GET /logins, DELETE /logins/{id} |
login | your CLI logins (current marks this one); end one |
GET /limits |
login or key with usage:read (not a Space credential) |
your account's limits (no use: that is the node's GET /quotas): {tenant, class, class_pinned, caps: {leases, spaces, awake, services, lease_cpu}, label_caps, zero_means, raise} (limits.md) |
PATCH /limits/labels {labels: {"<key>" | "<key>=<value>": {awake?, spaces?} | null}}, PUT /limits/labels {labels: {...} | null} |
spaces:write (not a Space credential) |
per-label limits for your end users: PATCH merges (null removes a selector), PUT replaces (null clears). Answers as GET /limits. 400 invalid_request with details.field (labels.end_user.awake). The nodes enforce them within ~2 s (limits.md); shark quotas labels |
PUT /github/app {app_id, private_key} |
login or key with secrets:write (not a Space credential) |
bring your own GitHub App (a platform whose users connect their GitHub to your App): register it once; the key (PEM or its base64) is sealed and never returned; GitHub is asked whether the pair is an App (400 github_app_refused if not). {registered, app_id, slug, verified} |
GET /github/app, DELETE /github/app |
spaces:read / secrets:write |
{registered, app_id, slug, linked_spaces} (never the key) / forget it and every Space link to it |
POST /github/app/link {space, installation_id, repos?} |
secrets:write |
a token of that installation of your App (≤ 1 h, contents + pull requests, narrowed to repos), {token, expires_at, repos, space, refresh: "node"}, Cache-Control: no-store. Put it in the Space as GITHUB_TOKEN (github preset; the CLI and SDKs do); the Space's node keeps it fresh while the Space is used. 409 github_app_not_registered, 409 github_not_connected (not an installation of your App), 403 github_no_access (a repository outside it) |
Errors: 403 user_credential_required (an API key on a route that needs a person), 403 personal_workspace (inviting
or adding someone to a personal workspace, or deleting it: create a team instead).
From a browser (the dashboard)#
The dashboard calls the node directly. It asks the account service for a short-lived node token with the signed-in
session: POST https://api.sharkmagic.si/v1/console/orgs/{org}/node-token (cookie session) →
{"token", "expires_at", "expires_in" (≤ 600), "refresh_after_s", "scopes", "role", "node", "nodes": [{id, url}]}. The
token carries the person's role in that org: owner, admin and member get every node scope (a member works on
Spaces, secrets and egress included, exactly as with their CLI login; billing, keys and members stay owner/admin at the
account service); at zero balance only read scopes ("billing": "credits_exhausted"). It is
refused for another org (404) and without a session (401); it never outlives the session, and sign-out, an expired
session, a role change or DELETE /v1/console/node-token make every node refuse it within ~2 s. The node answers CORS
only for the dashboard origins it is configured with (sharkfs api -cors-origins, never *; the hosted node allows
the console's origin, SHARK_CORS_ORIGINS in its config): the token goes in
Authorization, never in a cookie, and no response carries Access-Control-Allow-Credentials. Scripts and agents use
the same node routes with a node token from POST /v1/token (an account key) instead.
Usage#
| route | scope | status | what |
|---|---|---|---|
| `GET /usage?from&to&group_by=day | space | kind` | usage:read |
GET /spaces/{id}/usage |
usage:read |
beta | the same for one Space |
Counts and durations only: usage never contains command text, paths or file contents. Balance and credits live in the account service (billing.md).
Coming (answer 422 unsupported today)#
/catalog/services, /spaces/{id}/services[/{name}[/checkpoint]] (declare services with .shark/services.toml
instead: concepts.md); /spaces/{id}/snapshots, /snapshots/{snap_id},
/spaces/{id}/restore; /spaces/{id}/tokens (Space-scoped tokens); /spaces/{id}/connectors,
/spaces/{id}/model-gateway; /events, /webhooks; /tenants (sub-accounts); /auth/device, /auth/token,
/auth/revoke, /balance on the node (the account service at api.sharkmagic.si serves device login, tokens and
balance). Each 422 names the phase in details.phase.
Route → CLI → SDK#
Every SDK method with its signature and errors: sdk-python.md, sdk-typescript.md.
| route | CLI | Python | TypeScript |
|---|---|---|---|
POST /spaces |
shark create [--name N] [--label k=v] [--profile sdk|devbox] [--account-secrets …] [--github on|off] … |
shark.spaces.create(name=…, account_secrets=…, github=…) |
shark.spaces.create({name, accountSecrets, github}) |
GET /spaces |
shark ls [--label k:v] |
shark.spaces.list(label="k:v") |
shark.spaces.list({label}) |
DELETE /spaces/{id} |
shark rm <space> [--purge] |
space.delete() |
space.delete() |
POST /spaces/{id}/exec (stream) |
shark run <space> -- '<cmd>' |
space.run(cmd), space.stream(cmd), space.start(cmd) |
space.run(cmd), space.stream(cmd), space.start(cmd) |
GET /spaces/{id}/ops, /ops/{op_id}/events |
shark ops <space>, shark logs <space> <op-id> |
space.ops(), space.op(id).events() |
space.ops(), (await space.op(id)).events() |
POST /spaces/{id}/fork |
shark fork <space> [-n N] [--account-secrets …] |
space.fork(count=N, account_secrets=…) |
space.fork({count, accountSecrets}) |
POST /spaces/{id}/hibernate, /wake |
shark hibernate, shark wake |
space.hibernate(), space.wake() |
same |
| files | `shark files ls | get | put |
GET /usage |
`shark usage [--by space | day]` | shark.usage.get() |
GET /me, /quotas, /capabilities |
shark whoami, shark quotas |
shark.me(), shark.limits(), shark.capabilities() |
shark.me(), shark.limits(), shark.capabilities() |
PUT/GET /spaces/{id}/secrets, DELETE …/{name} |
shark secrets set|ls|rotate|edit|rm <space> … |
space.secrets.set/list/get/rotate/edit/remove/reference/usage |
same |
PUT/GET /secrets, DELETE /secrets/{name}, GET …/secrets/{name}/usage, GET /secret-presets |
shark secrets set|ls|rm|usage --account …, shark secrets usage <space> NAME, shark secrets presets |
shark.secrets.set/list/rotate/edit/remove/usage/presets |
same |
GET /spaces/{id}/secrets/status, GET /secrets/status |
shark secrets status [--account | <space>] [--agent A] [--preset P] [--json] |
space.secrets.status(agents=…), shark.secrets.status() |
space.secrets.status({agents}), shark.secrets.status() |
GET/PUT /space-defaults |
shark space defaults [--account-secrets all|none|default] [--github on|off|default] [--harness A|default] |
shark.space_defaults.get()/set() |
shark.spaceDefaults.get()/set() |
GET /agents |
shark code --agent list [--json] |
— | — |
GET/PUT/PATCH /spaces/{id}/egress, GET /egress/profiles, GET …/egress/denials |
shark egress profiles|show|use|add|rm|deny|undeny|denials|clear|set <space> … |
space.egress.get|set|use|allow|disallow|deny|undeny|denials, shark.egress.profiles(); space.secrets.set(…, allow_egress=True) adds the preset's profile |
space.egress.*, shark.egress.profiles() |
GET /spaces/{id} (settings), PATCH /spaces/{id} |
shark space get|set <space> … |
space.settings(), space.update(…) |
space.settings(), space.update(…) |
PUT/GET/PATCH/DELETE /spaces/{id}/ports/{port}, …/sleep, …/logs, …/url |
shark expose <space> PORT -- cmd, shark expose <space> --ls|--public PORT on|off|--sleep PORT|--logs PORT|--rm PORT, shark open <space> PORT |
space.ports.declare/list/get/set_public/sleep/logs/remove/login_link |
space.ports.declare/list/get/setPublic/sleep/logs/remove/loginLink |
GET /ports |
shark expose --ls --all [--awake] [--json] |
shark.ports.list(state=…) |
shark.ports.list({state}) |
| terminal (coming) | shark connect |
— | — |
