sharkmagic.docs
HTTP API

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 header Shark-Op-Id for file writes); create and fork take an Idempotency-Key header. A retry with the same id replays the stored response ("replayed": true, header Shark-Replayed: true); the same id with a different request is 409 idempotency_mismatch.
  • Headers on every response: Shark-Request-Id. Retryable 429/503 carry Retry-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 JSON data:. Reconnect with Last-Event-ID to resume; nothing is lost or repeated. While a command is silent the server sends event: ping every 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; 200 with the result below.
  • stream: Accept: text/event-stream; events start, stdout, stderr ({"data", "encoding": "utf8"|"base64"}), detached, then exit (the result) or error.
  • async: "wait": false; 202 with 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) requires scope.hosts: the command sees a placeholder shk_ph_…; the node's egress proxy swaps in the value only toward those hosts (default location Authorization: Bearer). The value never enters the VM.
  • expose: "exec" puts the value in the environment of commands that list it ("secrets": ["DB_URL"] in exec). The response carries warnings: [{"code": "secret_exposed_to_code", …}].
  • A scope host the Space's egress does not allow is warned (scope_host_not_allowed).
  • "keep_value": true instead of value changes a stored secret's expose, scope or apply and keeps its value (shark secrets edit); 400 invalid_request when nothing is stored under the name, or with a value too.

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_found and 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 — —