# 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](#account-service-tokens-workspace-orgs-keys-logins)).

**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](quickstart.md#path-c-raw-http-for-any-language)). 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](errors.md). Limits: [limits.md](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](limits.md#per-label-limits)). 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](#sdk-spaces-and-devbox-spaces-profile)), `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:

```json
{"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](shark-code.md#choosing-the-agent---agent---remember---forget)); 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

```json
{"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](#secrets-and-egress)) |
| `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](concepts.md#background-processes)) |
| `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):

```json
{"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](concepts.md#lease-one-command-one-microvm).
`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|done` |
| `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"|"INT"|"KILL"|"HUP"}` |
| `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](concepts.md#background-processes).

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

```json
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):

```json
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.

```json
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](concepts.md#egress-policy).

## 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](billing.md#what-is-metered)).

| 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|false}` (`403 plan_restriction` where public previews are off) |
| `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](limits.md#your-account)) |
| `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](limits.md#per-label-limits)); `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` | beta | rows of `commands`, `command_s`, `command_vcpu_s`, `service_awake_s`, `process_awake_s`, `storage_gib_h`, `egress_bytes`; `provisional_after` marks the current day; `metered: false` if the node is not metering |
| `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](billing.md)).

## Coming (answer `422 unsupported` today)

`/catalog/services`, `/spaces/{id}/services[/{name}[/checkpoint]]` (declare services with `.shark/services.toml`
instead: [concepts.md](concepts.md#services-that-sleep)); `/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-python.md), [sdk-typescript.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|rm|mv|push|pull`, `shark cp [-r]` | `space.files.read/write/list/remove/move/upload_dir/download_dir` | `space.files.read/write/list/remove/move` |
| `GET /usage` | `shark usage [--by space|day]` | `shark.usage.get()` | `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` | — | — |