# Shark Magic > Serverless sandboxes for AI agents: each agent has a persistent Space (its files, plus its own persistent services when it needs them); each command runs in a fresh Firecracker microVM that exists only while the command runs; idle time (the model thinking) runs no VM and bills no compute. ## Key facts - What: a Space is a persistent workspace (content-addressed files, optional per-Space Postgres 16/17 or Redis 7 that sleep and wake on connect). Every command (`exec`) gets a fresh microVM with its own Linux kernel (8 vCPU / 4 GiB), the Space mounted; file changes are committed whatever the exit code; the VM is then destroyed. Process memory and shell state (`cd`, `export`) do not persist between commands. - Status: early access. Request access: https://sharkmagic.si - Endpoints: node API `https://hel1.sharkmagic.si/v1` (Spaces, commands, files, fork); account service `https://api.sharkmagic.si/v1` (sign-in, keys, node tokens, placement, workspace and teams, credits); previews `https://-.preview.sharkmagic.si` (exposed processes: private by default, one-time 60 s login link, sleep when idle, wake on request; billed while awake by size; CLI and API, no SDK method yet). - Auth: an account key `shk_live__` goes only to the account service: `POST https://api.sharkmagic.si/v1/token` (key as Bearer) returns a 5-minute node token and the node URL; node calls carry `Authorization: Bearer `. The CLI and SDKs do this themselves: set only `SHARK_API_KEY` (or `shark login --api-key -`). Do not set `SHARK_API`: it means direct mode (a self-hosted node's own keys), where an account key answers 401. The account comes from the key; another account's Space answers 404. Every account starts with a personal workspace; teams are opt-in (`shark org`). Scopes: spaces:read, spaces:write, exec, files:read, files:write, usage:read (default key) plus secrets:write, network:write, admin on request; `shark keys create --space SP` limits a key to one Space. Never put keys in prompts, code or the Space. - Exactly-once: send `op_id` with each command (use the tool-call id); a retry replays the stored result (`"replayed": true`), never runs twice; a different request with the same `op_id` is 409 idempotency_mismatch. `Idempotency-Key` header on create and fork. - Exec modes: sync JSON (timeout_s default 120, max 900), SSE stream (`Accept: text/event-stream`, max 7200), async (`"wait": false`, 202). A non-zero exit is HTTP 200 with `exit`; `shark_status` = ok | conflict (re-run with new op_id) | timeout | infra_timeout | infra_error (retry same op_id) | signaled. - Fork: `POST /v1/spaces/{id}/fork {"count": 1..50, "inherit_labels": true}`: O(1) on files, copies env and egress, not secrets, processes or service memory. - Secrets: `PUT /v1/spaces/{id}/secrets`; default `expose: inject` (command sees placeholder `shk_ph_…`, the node adds the real value only toward `scope.hosts`; the value never enters the VM); `expose: exec` is an explicit opt-in. Account secrets (`PUT /v1/secrets`, `shark secrets set --account`) reach every Space (`apply: all`, forks included) or only those that reference them (`apply: ref`); a Space secret of the same name wins. `shark secrets status --json` lists what is missing with the fixing command. - Background processes: on the hosted node a process a command leaves running (`cmd &`, `nohup`) is kept as a job of the Space (stderr: `shark: … keep running as job bg-…`) and answers later commands at `127.0.0.1:` (beta: 10–20 s to come up, not visible to later `ps`, cannot be stopped from inside the Space yet; start it with `timeout N`). Python: system pip is refused, use a venv (`python3 -m venv .venv && .venv/bin/pip install …`). - Egress: per Space, `PUT /v1/spaces/{id}/egress {"mode": "allowlist"|"deny"|"open", "allow": [...], "base": "coding"|"none"}`; hosted default allowlist + package registries and GitHub; private ranges always denied. - Errors: `{"error": {"code", "message", "retryable", "request_id", "details"}}`; retryable 429/503 carry Retry-After. 402 credits_exhausted (top up; files stay readable), 429 lease_cap_reached (command did not start; retry same op_id), 429 quota_exceeded, 503 out_of_capacity, 507 insufficient_storage, 422 unsupported (not served on this node yet). - Limits (hosted defaults; live values: `GET /v1/quotas`, `GET /v1/capabilities`): per account, by class: 7 Spaces awake at once, 20/10/50 Spaces held and 4/2/6 commands at once (new / promo-only / standard; partner, for design partners and SDK platforms: 200 held, 100 awake, 12 at once; `429 quota_exceeded` with `details.quota` spaces|awake|label_spaces|label_awake and `details.raise`, `429 lease_cap_reached`); per-label limits cap each end user of a platform (`shark quotas labels set end_user --awake 3`), 50 GiB disk per account, sync output last 1 MiB per stream, op output kept 7 days (32 MiB per stream), file upload 512 MiB, archive 2 GiB, 100 requests/s per key. - Measured on the hosted hardware (AX41, 2026-11-05): lease 44 ms; create → first command 65 ms; 24 new Spaces with a first command in 342 ms; sleeping Space wakes in 58 ms; sleeping Postgres answers in 37–40 ms; 91–96 % of a command's time is the user's command. - Billing: plans Devbox $5/month (includes $5 usage), Studio $25/month (includes $25) and Scale $100/month (includes $100); one active plan, included usage spent before prepaid credit (no roll-over). Compute on the standard 8 vCPU / 4 GiB shape is $0.12 per compute-hour, billed per second only while a command runs; services are $0.03/h (2 vCPU / 2 GiB) and $0.06/h (4 vCPU / 4 GiB; standard service rate $0.06/h); storage is $0.08/GiB-month with the first 5 GiB free per Space per billing cycle; egress is $0.03/GB with the first 10 GiB free per Space per billing cycle. Overage only; idle and file reads are free. Prices are published here and in billing.md; live limits from `GET /v1/quotas`. - Not supported today: GPUs; non-Linux guests; process memory across commands; services/snapshots/webhooks/events API routes (422); E2B SDK compatibility. Coming on the hosted node: `shark connect` (exposed processes and preview URLs are live). ## CLI cheat sheet ```sh shark login --api-key - # store an account key (prompted or stdin); a person: shark login shark create --name demo # prints the Space id shark quotas --json # your caps with current use (spaces, awake_spaces, …) shark run demo -- 'npm ci && npm test' # stream output; exit = the command's exit code shark run demo --detach -- 'make long'; shark logs demo shark fork demo -n 3 # O(1) copies shark cp -r ./repo demo:repo ; shark cp demo:out.json . # files in and out (reads start no VM) shark ops demo ; shark files ls demo ; shark usage shark hibernate demo ; shark wake demo ; shark rm demo shark code --agent claude --remember account --repo owner/name demo ; shark code --continue demo # a coding agent in the Space (beta; --agent list; no terminal: --agent or a remembered one, else harness_required); back to its last conversation shark expose demo 3000 -- npm run dev ; shark open demo 3000 ; shark expose demo --rm 3000 # a preview (billed while awake) shark connect demo # coming on the hosted node ``` ## Docs - [Overview](index.md): what Shark is, when to use it and when not. - [Quickstart](quickstart.md): CLI and SDK (Python, TypeScript) paths to `echo hello` and a fork, with expected output. - [Concepts](concepts.md): Space, lease, op_id, services, exposed processes, fork, hibernation, durability, secrets, egress, keys, credits; vocabulary vs E2B, Daytona, Modal and boat. - [What you can do](what-you-can-do.md): the jobs SharkBox does well (coding agent, tests, forks, UI evidence, previews, services, secrets), a recipe for each, and what it does not do yet. - [Agents](agents.md): Claude Code, Codex and Cursor via `shark run`; your own loop via the SDK with op_id = tool-call id; unmodified Claude Code via the shell shim and `shark-rg`; `sharkfn` over MCP. - [shark code](shark-code.md): Claude Code, Codex or OpenCode running inside your Space (beta): repository, terminal, conversation history, model credentials. - [AGENTS.md snippet](agents-md.md): paste-ready instructions for AGENTS.md / CLAUDE.md, and the Claude Code skill (`shark skills install`). - [HTTP API](api.md): every /v1 route with scope, status (GA-path, beta, coming), bodies and the route → CLI → SDK map. - [CLI](cli.md): every `shark` verb, configuration and exit codes. - [Python SDK](sdk-python.md): every class and method (signature, parameters, return type, errors, example): Spaces and their sdk defaults, commands, files, background jobs, secrets, egress, GitHub, usage, async client. - [TypeScript SDK](sdk-typescript.md): the same for TypeScript, and what only the Python SDK has. - [Errors](errors.md): every error code with retryable, Retry-After and what to do. - [Limits](limits.md): timeouts, sizes, quotas and how to read your live values. - [Framework integrations](integrations.md): LangChain, LangGraph, smolagents and Prefect with the Python SDK. ## Trust - [Security](security.md): what we do (microVM per command, secrets outside the VM, egress policy) and how to report a vulnerability. - [Numbers](numbers.md): measured numbers with machine, date and proof id; what is not ready. - [Billing](billing.md): plans, usage rates, credits and what is metered. - [FAQ](faq.md): short answers. ## Optional - [Fit finder](fit-finder.md): a prompt to paste into your coding agent that maps your deployed agents and where Shark fits or does not. - [CI and Playwright (design note)](use-cases/ci-playwright.md): planned, not measured. - [All docs in one file](llms-full.txt) --- # Shark Magic > Serverless sandboxes for AI agents. Each agent gets a persistent **Space**: its files, plus its own persistent > services when it needs them. Each command runs in a fresh microVM that exists only while the command runs, so the > time the model spends thinking runs no VM at all. Status: **early access** on the hosted node (`https://hel1.sharkmagic.si`). Request access at [sharkmagic.si](https://sharkmagic.si). Every route, flag and verb in these docs exists in the code; every number comes from a pre-registered proof and says which machine and date it is from. What is not ready is marked **beta** or **coming** where it appears. ## The model in six lines 1. **Space**: an agent's persistent workspace, files on a content-addressed filesystem, with optional Postgres or Redis that belong to it alone. 2. **Lease**: every command gets a fresh Firecracker microVM with its own kernel and the Space mounted. When the command ends, its file changes are committed and the VM is gone (44 ms per lease on the hosted hardware). 3. **Op**: a command is a resource with an `op_id`. Retries never run it twice; output can be re-attached from anywhere. 4. **Fork**: O(1) copies of a Space at an exact commit, up to 50 per call, for parallel attempts, evals and shards. 5. **Sleep and wake**: idle Spaces hibernate into ~57 KB on disk and wake on the next request (58 ms measured); services sleep and wake on connect (37–40 ms measured). 6. **Billing per second of work**: commands, awake services and processes, storage and egress; idle is not billed. ## Use it when - Your agents spend most of their time thinking: in 3,921 public autonomous sessions the model was 82.7 % of the time and commands 10.2 % (P88, [billing.md](billing.md)). - You run many agents, users or customers, each with its own files and services, mostly idle. - You need bursts of fresh environments that start fast and vanish (24 new Spaces with a first command each finished in 342 ms on the hosted hardware). - You want exactly-once command semantics and secrets that never enter the sandbox. ## Do not use it (today) when - **You need process memory across commands** (a REPL kept in RAM, a debugger session). Files persist; memory does not, except in services and kept processes. - **You need GPUs, non-Linux guests, an SLA or multi-region.** None exist yet. ## Start here - [Quickstart](quickstart.md): CLI or SDK, `echo hello` and a fork in under 10 minutes. - [Concepts](concepts.md): every term once, and a vocabulary map from E2B, Daytona, Modal and boat. - [Agents](agents.md) and the [AGENTS.md snippet](agents-md.md): point Claude Code, Codex, Cursor or your own loop at Shark. - Reference: [API](api.md), [CLI](cli.md), SDKs for [Python](sdk-python.md) and [TypeScript](sdk-typescript.md), [errors](errors.md), [limits](limits.md), [framework integrations](integrations.md). - Trust: [security](security.md), [numbers](numbers.md), [billing](billing.md), [FAQ](faq.md). - Not sure Shark fits? [Fit finder](fit-finder.md): a prompt for your coding agent that maps your stack. --- # Quickstart From zero to a command running in a Space, then a fork of it. Two paths, each under 10 minutes: the **CLI** and the **SDK** (Python or TypeScript). Both talk to the same API. ## 0. What you need - **An account API key.** Shark is in early access: your invite contains an account key (`shk_live_…`). Request access at [sharkmagic.si](https://sharkmagic.si). Once you are logged in, `shark keys create --name ci` makes more ([cli.md](cli.md#account)). - Keep the key in the environment or in the CLI's credentials file. Never paste it into code, into a file of the Space, or into an agent's prompt. ```sh export SHARK_API_KEY=... # from your invite; the CLI can store it for you instead (step A1) ``` That is the whole configuration. The key belongs to the **account service** (`https://api.sharkmagic.si`, the default): the CLI and the SDKs exchange it there for short-lived node tokens and send each call to the node that holds your Space (the hosted node (`https://hel1.sharkmagic.si`)). The key itself never goes to a node. **Do not set `SHARK_API`.** `SHARK_API` (or `shark login --api …`) puts the CLI and the SDKs in *direct mode*: the key goes to that one node as is, and only a self-hosted node's own keys work there. An account key in direct mode answers `401 unauthenticated`. If you set it earlier: `unset SHARK_API`, then `shark login --api-key -` again. **Installing.** - **CLI** (Linux and macOS, amd64 and arm64; one static file): ```sh curl -fsSL https://dl.sharkmagic.si/install.sh | sh ``` The script picks the build for your OS and CPU, checks its sha256 against the release's `SHA256SUMS` and refuses on any mismatch, then installs `shark` into `~/.local/bin` (`/usr/local/bin` as root; `SHARK_INSTALL_DIR=…` to choose, `SHARK_VERSION=0.2.0` to pin). Prefer to look first: download `install.sh` from the same page, read it, run it. Or take the binary for your platform and `SHA256SUMS` from the [download page](https://dl.sharkmagic.si/) and check it with `sha256sum -c --ignore-missing SHA256SUMS`. Later: `shark update` says whether a newer release exists; `shark update --install` replaces the binary (same checksum check). - **SDKs**: not on PyPI/npm yet (the package names are being decided); your invite links the build. The commands below use these placeholders: | placeholder | what it installs | provisional name | |---|---|---| | `` | the Python SDK (3.9+, no dependencies) | `pip install shark-sdk`, imported as `shark` | | `` | the TypeScript SDK (Node 22.18+, no dependencies; ESM with types) | `npm install @sharkmagic/sdk` | From a checkout of the source, the same things are `go build -o ~/bin/shark ./cmd/shark`, `pip install ./sdk/python` and `npm install ./sdk/typescript` (its `prepack` builds `dist/`). --- ## Path A: the CLI ### A1. Install and log in (1 min) ```sh curl -fsSL https://dl.sharkmagic.si/install.sh | sh shark login --api-key - # paste the key at the prompt; it never enters argv or history ``` Expected: ```text Logged in with an API key: tenant . Credentials in /home/you/.shark/credentials (0600). ``` From a script or CI, pipe it (`shark login --api-key - < keyfile`) or just export `SHARK_API_KEY`. At a terminal, `shark login` alone logs you in through the browser with a one-time code instead (a login of yours, not a key). Check it: ```sh shark whoami ``` ```text tenant (), api_key key:…, scopes [exec files:read files:write spaces:read spaces:write usage:read] control plane https://api.sharkmagic.si ``` Every account starts with a **personal workspace** (just you; nothing to set up). Teams are opt-in: `shark org create`, `shark org invite` ([cli.md](cli.md#account)). ### A2. Create a Space (30 s) ```sh shark create --name demo ``` ```text tti-ec8e46415dcc2dc3182daae2bc8a8aa2 ``` The id is `-<32 hex>`; every command below accepts the id or the name (`demo`). ### A3. Run commands (1 min) Quote the whole command line: `shark run` sends it to `bash` in the Space. ```sh shark run demo -- 'echo hello > hello.txt && cat hello.txt && uname -s' ``` ```text hello Linux ``` That command ran in a fresh microVM that no longer exists; `hello.txt` was committed to the Space. The next command sees it, and `shark run` exits with the command's own exit code: ```sh shark run demo -- 'ls && exit 3'; echo "exit code: $?" ``` ```text hello.txt exit code: 3 ``` Output streams while the command runs. If your connection drops, `shark run` reconnects and resumes the output; the command never runs twice (each call has an `op_id`). Ctrl-C stops the command, not just the CLI. ### A4. Fork it (30 s) ```sh shark fork demo -n 2 ``` ```text tti-376de78ec13b0d33216c9abf966d6267 tti-9909f1e3035a270e6baeb024b6f54f87 ``` Each fork is a new Space at the same commit, with nothing copied. Run something in one: ```sh shark run tti-376de78ec13b0d33216c9abf966d6267 -- 'cat hello.txt' ``` ```text hello ``` ### A5. Move files in and out (1 min) ```sh shark cp -r ./my-project demo:my-project # upload a directory (one commit) shark cp demo:hello.txt - # print a file; reads never start a VM shark files ls demo ``` ### A6. Exposed server and preview Live on the hosted node: the server runs in its own microVM, private by default, sleeps when idle and wakes on the next request ([concepts.md](concepts.md#exposed-processes-and-preview-urls)). It is **billed while awake**, by size: remove it when you are done. ```sh shark expose demo 3000 --cwd app -- node server.js # a server that sleeps when idle and wakes on request shark open demo 3000 # one-time login link (60 s) to https://-3000.preview.sharkmagic.si shark expose demo --logs 3000 # its output shark expose demo --rm 3000 # stop it and revoke the preview cookies ``` `shark connect demo` (an interactive shell where each line runs as one command and `cd`/`export` carry over) is **coming**: it passed its local gates but is not yet verified on the hosted node. ### A7. Clean up ```sh shark ls shark rm demo ``` --- ## Path B: the SDK ### B1. Install (1 min) ```sh # Python # TypeScript ``` Both read `SHARK_API_KEY` from the environment (Python also `~/.shark/credentials`, written by `shark login`) and go through the account service for you, like the CLI. Leave `SHARK_API` unset (direct mode is for self-hosted nodes). ### B2. Python: create, run, read a file, fork (3 min) ```python from shark import Shark shark = Shark() # SHARK_API_KEY space = shark.spaces.create(name="quickstart") print(space.id, space.state) r = space.run("echo hello > hello.txt && cat hello.txt") print(r.exit, r.stdout.strip(), r.shark_status) print(space.files.read_text("hello.txt").strip()) # served from the committed tree, no VM for fork in space.fork(count=2): print(fork.id, fork.run("cat hello.txt").stdout.strip()) print("exit", space.run("exit 7").exit) # a failing command is a result, not an exception ``` Expected (ids differ): ```text tti-a47ffe076f139d770d99dce9a6db0ffe ready 0 hello ok hello tti-e0b78dd7df202284936c5803a9cdb88a hello tti-79083d0f699a6b800adba0a1742329d3 hello exit 7 ``` Useful next calls: `space.run(cmd, cwd=..., env=..., timeout=..., check=True)`, `space.stream(cmd)` (live events), `space.start(cmd)` (returns an `Op` at once), `space.files.write(...)`, `space.files.upload_dir(...)`, `space.hibernate()`, `space.delete()`. Async: `AsyncShark`. ### B3. TypeScript: the same (3 min) ```ts import { Shark } from "@sharkmagic/sdk"; const shark = new Shark(); // SHARK_API_KEY const space = await shark.spaces.create({ name: "quickstart-ts" }); console.log(space.id); const r = await space.run("echo hello > hello.txt && cat hello.txt"); console.log(r.exit, r.stdout.trim(), r.sharkStatus); const [fork] = await space.fork({ count: 1 }); console.log(fork.id, (await fork.run("cat hello.txt")).stdout.trim()); ``` Expected: ```text tti-8d5d6fa7ea7287b3cc76d720830cbb92 0 hello ok tti-d795718d53c4d099f95ada76cdac1e87 hello ``` ### B3b. What an SDK Space gets (1 min) Spaces made by the SDK are **sdk** Spaces: your account's secrets, your GitHub connection and background jobs do **not** reach them unless you ask, per Space (a platform's end users never see your keys): ```python sp = shark.spaces.create(labels={"customer": "acme"}, egress="coding") # nothing of the account sp.secrets.set("STRIPE_KEY", key, hosts=["api.stripe.com"]) # this Space's own secret (inject) sp.update(account_secrets=["OPENAI_API_KEY"], github=True) # opt in: one account secret, GitHub sp.run("python worker.py &", background=True) # keep what it leaves running for s in shark.spaces.list(label="customer:acme"): print(s.id) ``` ```ts const sp = await shark.spaces.create({ labels: { customer: "acme" }, egress: "coding" }); await sp.secrets.set("STRIPE_KEY", key, { hosts: ["api.stripe.com"] }); await sp.update({ accountSecrets: ["OPENAI_API_KEY"], github: true }); ``` `profile: "devbox"` (`profile="devbox"`) makes one like `shark create` does. Every field: [api.md](api.md#sdk-spaces-and-devbox-spaces-profile). ### B4. Clean up ```python for s in shark.spaces.list(name="quickstart"): s.delete() ``` --- ## Path C: raw HTTP (for any language) An account key goes only to the account service. Exchange it there for a **node token** (5 minutes; ask for a new one before it expires); the answer also names the node to call: ```sh curl -sS -X POST https://api.sharkmagic.si/v1/token -H "Authorization: Bearer $SHARK_API_KEY" \ -H 'Content-Type: application/json' -d '{}' # {"token":"…","token_type":"Bearer","expires_in":300,"tenant":"…","scopes":[…],"space":null,"node":{"id":"hel1","url":"https://hel1.sharkmagic.si"}} export SHARK_NODE=https://hel1.sharkmagic.si # node.url from the answer export SHARK_TOKEN=… # token from the answer (never log it) ``` Then call the node with the token: ```sh curl -sS -X POST "$SHARK_NODE/v1/spaces" -H "Authorization: Bearer $SHARK_TOKEN" \ -H 'Content-Type: application/json' -H 'Idempotency-Key: qs-create-1' -d '{"name":"curl-demo"}' # {"id":"tti-…","state":"ready","name":"curl-demo",…} curl -sS -X POST "$SHARK_NODE/v1/spaces/curl-demo/exec" -H "Authorization: Bearer $SHARK_TOKEN" \ -H 'Content-Type: application/json' -d '{"cmd":"echo hello","op_id":"qs-1"}' # {"exit":0,"shark_status":"ok","stdout":"hello\n","stderr":"","op_id":"qs-1","replayed":false,…} curl -sS -X POST "$SHARK_NODE/v1/spaces/curl-demo/fork" -H "Authorization: Bearer $SHARK_TOKEN" \ -H 'Content-Type: application/json' -H 'Idempotency-Key: qs-fork-1' -d '{"count":1}' # {"spaces":[{"id":"tti-…","fork_of":"tti-…","fork":{"bytes_copied":0,…},…}]} ``` For an existing Space, `{"space": ""}` in the token request returns the node that holds it. The SDKs and the CLI do all of this (tokens, refresh, placement) for you. Send the same `exec` again with `"op_id":"qs-1"`: you get the stored result with `"replayed": true`, and the command does not run a second time. Every endpoint: [api.md](api.md). ## If something fails | you see | it means | do | |---|---|---| | `not logged in` / `401 unauthenticated` | no key, a wrong or revoked one, or `SHARK_API` is set (direct mode: account keys do not work there) | `unset SHARK_API`, then `shark login --api-key -`, or check `SHARK_API_KEY` | | `404 not_found` on your Space | wrong id or name, or it belongs to another account | `shark ls` | | `429 lease_cap_reached` | your account already runs the maximum commands at once | wait `Retry-After` seconds; the CLI and SDKs do it for you | | `429 quota_exceeded` | an account cap: Spaces held (`details.quota: spaces`) or awake at once (`awake`) | `shark quotas`; delete a Space, or `shark hibernate` one you are not using | | `402 credits_exhausted` | balance at zero | top up; files stay readable | | `422 unsupported` | the route exists in the spec but not on this node yet | see the page for that feature | All codes: [errors.md](errors.md). Next: [concepts.md](concepts.md), then point your agent at Shark with [agents.md](agents.md) and the [AGENTS.md snippet](agents-md.md). Expected outputs were captured on 2026-11-05 against the real `sharkfs api` and these exact CLI and SDK builds, on a local server whose commands ran without a VM (so no timings are shown here); the login and `whoami` lines show the CLI's format with an account key (updated 2026-11-07). Account keys working on the hosted node through the account service (key → node token → command → metered usage → balance → revocation) was checked end to end on 2026-11-07. Measured timings on metal: [numbers.md](numbers.md). --- # Concepts Every word Shark uses, defined once, with what it guarantees today. Labels used on every page: - **GA-path**: served by the node API (`/v1`) and tested against the real server; part of early access. - **Beta**: served and tested, but only locally (no microVM in the loop) or with a known gap written next to it. - **Coming**: the route answers `422 unsupported` today, or the piece is a spec only. ## Space A Space is an agent's persistent workspace: **your files, plus your own persistent services when you need them**. - **Files** live in SharkFS, a content-addressed store (chunks and Merkle trees, like git objects) mounted lazily as a POSIX filesystem inside each command's VM. A command reads only the bytes it touches; identical files are stored once. - **History.** Every command that changes files lands as one commit stamped with its `op_id`. `GET /v1/spaces/{id}/audit` lists, per commit, the op and the paths it changed. - **A new Space is empty.** Put code in with the files API (`shark cp -r ./repo demo:repo`, `PUT /v1/spaces/{id}/files`, archive upload) or with a command (`git clone`, a package manager), within the Space's egress policy. - **Ids** look like `-<32 hex>` (128 random bits). A Space can also have a `name` (unique per account) and up to 32 `labels`; every route accepts the id or the name. - **State**: `ready` (no VM runs unless a command, process or service does) or `hibernated` (packed on disk, see [Hibernation](#hibernation-sleeping-spaces)). `GET /v1/spaces/{id}` never wakes a Space. GA-path. ### SDK Spaces and devbox Spaces A Space is made for one of two things, and says which in its `profile`, set at create: - **devbox** (`shark create`, `shark code`): one developer's Space. It takes the account's secrets and its GitHub connection, what a command leaves running stays as a background job, and an agent in it gets the in-Space `shark` CLI. - **sdk** (`POST /v1/spaces` from the SDKs or raw HTTP, the default there): a Space a platform makes for **its own end users**. Nothing of the account reaches it unless asked: no account secret (`account_secrets`), no GitHub (`github`), no Space CLI credential (`space_cli`), and what a command leaves running ends with it (`keep_background`). Each is a field at create, by `PATCH` and at fork; a fork keeps its source's choices. Kept jobs, services and exposed processes of either profile sleep when idle and wake on a connection; `keep_awake: true` opts a Space's jobs out (billed awake). [api.md](api.md#sdk-spaces-and-devbox-spaces-profile) has every field. ## Lease: one command, one microVM Every `exec` gets a **fresh Firecracker microVM with its own Linux kernel**, restored from a snapshot, with the Space mounted. The VM exists only while the command runs. Lease shape on the hosted node: 8 vCPU / 4 GiB. When the command ends: - its file changes are committed to the Space **whatever the exit code**, and the VM is destroyed; - you get `exit`, `stdout`, `stderr` (sync: the last 1 MiB of each; the full output stays readable as the op's event log), `shark_status`, `commit`, `changed_paths`, `run_s` (metered seconds, measured in the guest) and timings. **What persists between commands, and what does not.** This is the contract to design around: | persists | does not persist | |---|---| | the Space's file tree (every command's changes are committed) | process memory of a command (each command is a new VM) | | services' data (Postgres, Redis volumes, checkpointed separately) | shell state: `cd`, `export`, aliases. Every command starts in the Space root; use `cwd` and `env` per command, or `shark connect`, which carries them for you | | background processes kept as jobs ([below](#background-processes)), and exposed processes, until they exit | | **`shark_status`** says whether an unusual exit came from Shark or from your command: | `shark_status` | meaning | what to do | |---|---|---| | `ok` | your command ran to completion (any exit code: a non-zero exit is a normal `200`) | read `exit` | | `conflict` | a concurrent command of the same Space changed the same paths first; this one's file changes were not published | re-run with a **new** `op_id` | | `timeout` | your `timeout_s` fired | raise it, stream it, or run it async | | `infra_timeout` | the infrastructure deadline killed the VM; nothing published | shorten it or run it in the background | | `infra_error` | Shark failed (mount, store, VM) or the Space was busy | retry with the **same** `op_id` (the SDKs do it once for you) | | `capacity_unavailable` | the node was too busy to start the command (its VM got no CPU to boot); **it did not run** (`exit` 125, `retryable`, `retry_after_s`) | retry with the **same** `op_id` after `retry_after_s` (the CLI and the SDKs do it) | | `signaled` | stopped by a signal you sent (`/ops/{op_id}/signal`, Ctrl-C in `shark run`) | | **Concurrency.** Commands of one Space can run at the same time; each starts from the HEAD it reads and lands by three-way merge (overlapping paths give `conflict`). Per account, at most a fixed number of leases run at once (your account's value: `shark quotas`, [limits.md](limits.md#your-account)); beyond it a command waits in a queue and, if it waits too long, gets `429 lease_cap_reached` **before it starts** ([errors.md](errors.md)). **Inside a command, practical notes** (check a tool with `command -v` before relying on it): - **Python packages**: the system `pip install` is refused (the image's Python is "externally managed"). Use a virtual environment in the Space and call it by path, since `source` does not carry over between commands: `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, then `.venv/bin/python app.py`. The venv is part of the Space's files, so later commands and forks reuse it. - **Disk**: `df` inside a command currently shows the host's disk, not your account's quota; do not plan from it. The account's disk quota is enforced with `507 insufficient_storage` ([limits.md](limits.md)). GA-path. ## Op and `op_id`: exactly once A command is a resource, the **op**, named by its `op_id` (1–64 of `[A-Za-z0-9_.:-]`). - **Exactly once.** A retry with the same `op_id` (lost response, dropped connection, a crashed client) returns the stored result with `"replayed": true` and header `Shark-Replayed: true`. The command does not run twice. A retry that arrives while the op still runs joins it. - **Mismatch is an error, not a silent replay.** The same `op_id` with a different request (`cmd`, `cwd`, `env`, `stdin`, `timeout_s`, `background`) answers `409 idempotency_mismatch`. - **Re-attach from anywhere.** `GET /v1/spaces/{id}/ops/{op_id}` (state and result) and `.../events` (Server-Sent Events, resumable with `Last-Event-ID`, nothing lost or repeated) work while it runs and after it ended. Output is kept 7 days, then `410 op_expired` (its commit stays in `audit`). - **Use the tool call id.** In an agent loop, pass the model's tool-call id as `op_id`: a harness that resumes from a checkpoint and replays a tool call gets the stored result. The SDKs and `shark run` generate an `op_id` per call and reuse it on their own retries. - **`Idempotency-Key`** (header, 1–255 chars, kept 24 h) does the same for create and fork: a retried create never makes two Spaces. Three ways to run an op: **sync** (default; JSON when it ends; `timeout_s` max 900 s), **stream** (`Accept: text/event-stream`; SSE while it runs; max 2 h) and **async** (`"wait": false`; `202` with the op, follow its `events_url`; max 2 h). GA-path. ## Services that sleep A Space can declare packaged services. Each runs in **its own VM with its own data volume**, belongs to that Space alone, is paused or snapshotted when nothing talks to it, and wakes when a command connects to `127.0.0.1:`. | service | port | VM shape | env your commands see | |---|---|---|---| | `postgres@16`, `postgres@17` | 5432 | 2 vCPU, 1024 MiB | `DATABASE_URL`, `POSTGRES_URL`, `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` | | `redis@7` | 6379 | 1 vCPU, 512 MiB | `REDIS_URL`, `REDIS_HOST`, `REDIS_PORT` | Declare one by committing `.shark/services.toml` to the Space (with the files API or a command): ```toml # .shark/services.toml [postgres] version = "17" ``` Measured on the AX41 (2026-11-05): a sleeping service answers its first query in **37–40 ms** (Postgres, the example the measurement used). The data volume is not part of the file tree; it is checkpointed separately. **Beta.** In every measurement the file was in the Space before its first command. Declaring it from a running Space is expected to take effect from the next command but is not yet measured end to end. The services API (`/v1/spaces/{id}/services`, `/v1/catalog/services`) is **coming** (`422` today). ## Background processes A process still running when the command's shell exits either ends with the command's VM or, on a node that keeps background jobs (job promotion; on in hosted `shark code`, and `$SHARK_CONTEXT` says `can.background_after_command`), stays alive as a **job** of the Space (`bg-1`, …) in its own VM. The command returns at once and prints one line naming the job, its ports and its log file. - **Works:** later commands reach a kept job's ports at `127.0.0.1:`; it keeps writing to the Space (its writes land when it snapshots or ends); it sleeps when idle and a connection wakes it. - **Not like a local shell:** it is in another VM, so `ps`, `pkill`, `kill %1` in later commands do not see it. Use `shark ps`, `shark logs bg-1`, `shark kill bg-1` (inside `shark code` with the agent's own Space credential; from outside with ``), or `GET /v1/spaces/{id}/processes`, `…/processes/{pid}/logs`, `POST …/processes/{pid}/signal`. Its log is committed output: it grows at the job's snapshots and at its end, not byte by byte. - **Bound it:** `(timeout 1800 npm run dev > dev.log 2>&1 &)`; jobs also end when the Space hibernates. Nothing else stops a job nobody remembers. - **Wait in the starting command:** between an agent's commands the Space may be frozen while the agent thinks, and a job still starting freezes with it, so start the server and poll its port in the same command (`… & for i in $(seq 120); do curl -sf localhost:3000 >/dev/null && break; sleep 0.5; done`). Beta: the metal proofs ran with job promotion; P131 measures the agent path. For a server you want to reach from a browser, use an [exposed process](#exposed-processes-and-preview-urls). ## Exposed processes and preview URLs An **exposed process** is a long-running server (a dev server, an API, a demo app) declared on a port: ```sh shark expose demo 3000 --cwd app -- npm run dev shark open demo 3000 # one-time login link, then the preview in your browser ``` - It is a **sleeping service of the Space**: it starts on the first connection, sleeps after `idle_s` without traffic (default 30 s), and wakes on the next request. - It **follows HEAD**: a file written by any command is visible to it before the next request it serves (hot reload works, including Vite HMR over WebSocket). What the process itself writes does not become a commit. - **Preview URL**: `https://-.preview.sharkmagic.si`. Private by default: `shark open` mints a one-time link (60 s) that sets a cookie for that preview host only. `--public` makes it reachable without the link, where the account allows it. - `shark expose --logs PORT` shows its output, `--sleep PORT` puts it to sleep now, `--rm PORT` stops it and revokes every preview cookie. - **Billed while awake, by size; asleep it is not** ([billing.md](billing.md#what-is-metered)). Auto-sleep is a safety net: remove what nobody is looking at. **GA-path, live on the hosted node**: each exposed process runs in its own microVM, and the whole flow above (private by default, one-time link, public toggle, logs, sleep after idle, wake on request, remove) passed end to end there (P130 flow PASS, 2026-11-07). Through the API and the CLI; the SDKs have no method for it yet (coming). A node without the exposed-process runner answers `422 unsupported` ("exposed ports and terminals are off on this node"). ## Terminal: `shark connect` An interactive shell over a WebSocket (`shark-pty.v1`): **each line you type runs as one op** in its own microVM, and `cd`, `export`/`unset` and `$?` carry over between lines. Ctrl-C interrupts the running line. No TTY inside the command (full-screen programs such as `vim` do not work this way). **Coming** on the hosted node: local gates PASS (P112), not yet verified there. ## Fork `POST /v1/spaces/{id}/fork` (`shark fork demo -n 5`) creates 1–50 new Spaces from the source's current commit (or a past one, `at`). - **Files: O(1).** A fork is a new ref to the same commit; nothing is copied (`bytes_copied: 0`), objects are shared and deduplicated. Measured: a fork returns in milliseconds of server time. - **Copied**: env defaults, egress policy, labels (unless `"inherit_labels": false`; then only the `labels` you pass). - **Not copied**: the Space's own secrets (account secrets reach the fork like any Space of the account), running processes and jobs, service memory. Service data volumes are not forked yet (`"services": "copy"` answers `422`). - Use it for evals, test shards, one branch per LangGraph thread, one task per Prefect run, or "try three fixes in parallel and keep the one that passes". GA-path. ## Hibernation (sleeping Spaces) A hibernated Space is packed into a small record on disk (~57 KB measured, P87) and holds no memory and no live slot. Any request for it wakes it first (the `exec` response then carries `wake_ms`); listing does not wake it. - `POST /v1/spaces/{id}/hibernate` / `.../wake` (`shark hibernate`, `shark wake`) do it explicitly. - Automatic hibernation after an idle period: per Space, `shark space set --idle 30m` (`dormancy` in `PATCH /v1/spaces/{id}`; `off` = never), else the node's setting. Your account can keep a limited number of Spaces awake at once (`awake_spaces` in `shark quotas`); hibernated ones do not count. - Measured on the AX41 (2026-11-05): first command of a sleeping Space **58 ms**. GA-path. ## Durability With durability on, each Space's commits are replicated in the background to an object store. - `POST /v1/spaces/{id}/checkpoint` returns once HEAD and its objects are in the store; that is the only durability promise. Without a checkpoint, the exposure is the replicator's lag, reported in `GET /v1/spaces/{id}/durable` (`behind`, `rpo_ms`). - Measured on the AX41 (2026-11-05): creating a durable Space costs **1.3 ms** of server time (earlier, with sequential store round trips, it cost ~0.8 s: P96). GA-path. ## Secrets: `inject` (default) vs `exec` `PUT /v1/spaces/{id}/secrets` stores values sealed per Space on the node (AES-256-GCM). Values are never returned by any read, never in the Space's tree, never replicated, and redacted from logs. | `expose` | what the command sees | use it for | |---|---|---| | **`inject`** (default) | `GH_TOKEN=shk_ph_…`: a per-command **placeholder**. The node's egress proxy swaps it for the real value only in requests to the hosts in `scope.hosts` (default header `Authorization: Bearer`) and scrubs it from responses. **The value never enters the VM.** | API tokens: GitHub, package registries, data APIs, model providers | | `exec` (explicit opt-in) | the real value, in the environment of the commands that list it in `"secrets": ["NAME"]` | a value code must read (a database URL, a signing key). The response warns `secret_exposed_to_code`: any code in that command can read and send it | `scope.hosts` must be allowed by the Space's egress policy (the `PUT` warns `scope_host_not_allowed` otherwise). Forks do not copy a Space's own secrets. Beta: the contract and the "never in the VM" property passed locally (P111 N1–N4, 2026-11-05); the same checks on metal are pending. **Account secrets** (`PUT /v1/secrets`, `shark secrets set --account NAME …`) are set once for the whole account and reach every Space's commands the same way (`apply: all`, the default, new Spaces and forks included), or only the Spaces that reference them (`apply: ref`). A Space secret with the same name always wins in that Space. Beta: passed locally with a canary value found 0 times outside its scope (P125, 2026-11-06). The guided `shark secrets` at a terminal, `shark secrets status` (what is missing, and the command that fixes it), `rotate` and `edit`: [cli.md](cli.md#secrets). ## Egress policy Each Space has an outbound network policy, set by API (`PUT`/`PATCH /v1/spaces/{id}/egress`, scope `network:write`), at create (`egress`), and inherited by forks. It governs the Space's commands **and its agent's session VM** (the harness's own calls: model API, login, catalogs). It applies from the Space's next command (the session VM: from its next start). **Nobody configures egress by hand.** A policy is **profiles** (named, versioned host lists Shark maintains, each host with its source and the date it was checked: `GET /v1/egress/profiles`, `shark egress profiles`) + the Space's own **allow** entries − its **deny** entries (deny wins over everything). ```json {"profiles": ["essentials", "opencode"], "allow": ["api.example.com", "db.example.com:5432"], "deny": ["*.githubusercontent.com"]} ``` | profile | opens | |---|---| | `essentials` | `npm`, `pypi`, `crates`, `go`, `github`, `gitlab`, `apt-ubuntu`, `docker-hub`, `shark-docs` (the devbox default) | | `coding` | the same without `gitlab` and `docker-hub`: the base every Space had before profiles, plus `shark-docs`; the SDK default | | `shark-docs` | `sharkmagic.si`: these docs, which `$SHARK_CONTEXT` tells every agent to read (in both defaults; an agent start adds it to a Space with its own list) | | `anthropic-api`, `openai-api`, `openrouter`, `google-ai` | one provider's API with an API key | | `anthropic-login`, `openai-login` | the Claude / ChatGPT subscription login and token refresh | | `claude-code`, `codex`, `opencode` | everything that harness needs (its API, its login, its model catalog) | | `browser-testing` | Playwright's browser downloads | | `none` / `open` | nothing from Shark / no control (mode `open`) | - **Plug and play.** `shark code --agent X` adds X's profile to the Space by itself, and with an API key the key's provider (`anthropic-api`, `openai-api`); a secret preset with `--allow-egress` adds its profile (`anthropic` → `anthropic-api`, `github` → `github`). They are listed in `auto` and in `effective` like any other; remove one with `-name`, or turn the behavior off with `"auto_profiles": false`. A Space in mode `deny` or `open` is never changed. - **Composition.** `+name` adds, `-name` removes; `-npm` on a Space with `essentials` replaces `essentials` by its other parts. `GET …/egress` returns `effective.entries`: every host with the profiles it came from (`"essentials/npm"`, `"opencode"`, `"space"` for yours) and `effective.denied`: what your deny list took out. - **Denials are visible.** `GET …/egress/denials?since=1h` (`shark egress denials`): each host the proxy refused, how often, when, and the profile that contains it (`suggest`, `add`: the command to run). Host names and ports only, never content. - Defaults: devbox Spaces start with `essentials`, SDK Spaces with `coding` (the node's `-egress-default`, hosted: `allowlist`). The old shape `{"mode", "allow", "base": "coding"|"none"}` still works: `base` maps to the `coding` profile or none, and reads keep returning it. - Modes: `allowlist` (the profiles + allow − deny). `deny`: nothing leaves. `open`: everything public (only for accounts allowed it; otherwise `403 plan_restriction`); deny entries need `allowlist`. - Web traffic is decided by name (HTTP Host / TLS SNI). `name:port` opens another TCP port of a name; `public-cidr:port` a non-HTTP destination by address. Bare IPs, private and link-local ranges are always denied. Beta: contract and enforcement passed locally (P111 E1/E2, 24/24 probes; profiles: each opens only its hosts, `proofs/egress-profiles/results/local-r1/`); the on-metal check is metal-queue row `egp1`. ## Account, keys and scopes - An **account** is a tenant. Everything you create belongs to the tenant of the key you used; another account's Space answers `404` (never `403`, so ids cannot be probed). - **Workspace and teams.** Every account starts with a **personal workspace** (just you; nothing to set up). Teams are opt-in: `shark org create`, `shark org invite`, `shark org use` ([cli.md](cli.md#account)). Keys, balance, usage and Spaces belong to one org. - **API keys** look like `shk_live__`: shown once, stored only as an HMAC. They live in the **account service** (`https://api.sharkmagic.si`): the CLI and the SDKs exchange a key there for 5-minute node tokens (`POST /v1/token`), so the key itself never reaches a node. Create, rotate and revoke them with `shark keys` or the dashboard; a revoked key's tokens stop working on the node within seconds. Keep keys in the environment (`SHARK_API_KEY`) or in `~/.shark/credentials` (mode 0600), never in code, files of the Space or prompts. - **Logins.** `shark login` at a terminal logs a person in through the browser with a one-time code; `shark logins` lists and ends them. A key is for scripts, CI and agents. - **Scopes**: a key carries a subset of `spaces:read`, `spaces:write`, `exec`, `files:read`, `files:write`, `services:write`, `secrets:write`, `network:write`, `usage:read`, `admin`. Default keys get the first four plus `files:write` and `usage:read`; ask for `secrets:write` and `network:write` when you need them. - **Space-scoped keys**: `shark keys create --name N --space SP` makes a key limited to one Space, for handing a single Space to an agent or a script. Agents under `shark code` already get a credential limited to their Space ([shark-code.md](shark-code.md#what-the-agent-knows)). - **Account secrets** (set once, used by every Space) and Space secrets: [Secrets](#secrets-inject-default-vs-exec). ## Credits Usage is metered per second of what runs (command seconds scaled by lease shape, service and job awake seconds, storage, egress) and paid from a prepaid credit balance. Idle time, the model thinking, and reads served without a VM are not billed. At zero balance new work answers `402 credits_exhausted`; files stay readable. Details: [billing.md](billing.md). ## Vocabulary: Shark vs E2B, Daytona, Modal and boat Closest equivalents, not identical semantics. The column that matters is the last one. | Shark | E2B | Daytona | Modal | boat | how Shark differs | |---|---|---|---|---|---| | **Space** | sandbox (+ its filesystem) | sandbox | sandbox + volume | sandbox (`bx_…`) | the Space is the files and services, not a running machine; nothing runs while it is idle | | **lease** (one command, one microVM) | — (commands run in the one long-lived sandbox VM) | — | — | — | every command gets a fresh VM with its own kernel; there is no machine to keep warm or stop | | **`exec` / op** with `op_id` | `commands.run` | `process.exec` | `Sandbox.exec` | `exec` | the command is a resource: exactly-once by `op_id`, re-attachable, output replayable for 7 days | | **fork** | — (pause/resume of one sandbox) | — | `snapshot_filesystem` → new sandbox | `fork` (from the last background snapshot) | O(1) on files at an exact commit, up to 50 per call | | **hibernate / wake** | pause / resume | stop / start, archive | — (terminate; volumes persist) | stop / resume | automatic: any request wakes it; first command measured at 58 ms | | **service** (Postgres, Redis per Space) | — (run it inside the sandbox) | — | — | — (systemd inside the VM) | its own VM and volume, sleeps and wakes on connect | | **exposed process** + **preview URL** | `getHost(port)` | preview link | tunnel / encrypted port | preview URL (`?_token`) | sleeps when idle, wakes on request, follows every commit; private by default (one-time link) | | **secret `inject`** | env vars | env vars | `Secret` (env) | credential proxy for AI keys | the value never enters the VM; the host adds it on the way out | | **egress policy** | internet access on/off | network allow list | `block_network` / CIDR allowlist | — (iptables inside the VM) | per Space by API, by name, private ranges always denied | | **credits** | usage billing | usage billing | usage billing | plan + wallet | prepaid credits, plus an optional monthly plan with a usage allowance; billed per second of command, service and job; idle is not billed | --- # What you can do with SharkBox > **SharkBox is the devbox side of Shark Magic**: `shark code` puts a coding agent (Claude Code, Codex, OpenCode) in a > persistent Space and gives every command it runs its own fresh microVM. The agent keeps working while you are away; > idle time runs no VM and bills no compute. The same primitives are in the HTTP API and the Python/TypeScript SDKs, so > a platform can build the same flow without a human terminal. Status legend: **GA** (on the hosted node today), **beta** (works, still changing), **planned** (design note, not measured), **not available** (do not promise it). ## The model in four lines 1. **Space**: the agent's persistent workspace: files with history, optional Postgres or Redis that belong to it alone. 2. **Command = fresh microVM**: every command gets its own Linux VM with the Space mounted; the VM exists only while the command runs. File changes are committed whatever the exit code; process memory and shell state do not persist. 3. **The agent keeps working**: the harness lives in the Space's session VM. You attach from any terminal and detach without stopping it; it sleeps only when it is neither working nor waiting for you. 4. **Fork and sleep**: a Space forks in O(1) for parallel attempts, and a sleeping Space or service wakes on the next request. Thinking time costs no VM. ## Pick your job | job | what SharkBox gives you | start with | status | |---|---|---|---| | A coding agent on a repository | the harness runs in the cloud; your laptop only shows the terminal | `shark code --repo acme/api --agent claude` | **beta** | | Run one command in a clean VM | fresh 8 vCPU / 4 GiB VM, streamed output, exact exit code | `shark run demo -- 'npm ci && npm test'` | **GA** | | Parallel attempts, evals, shards | O(1) copies of a Space at an exact commit (up to 50 per call) | `shark fork demo -n 8` | **GA** | | UI evidence for a pull request | screenshots, Playwright flows, recordings and traces from inside the command | `shark screenshot`, a Playwright script | **beta** | | A preview URL for a dev server | a sleeping exposed process with a private, one-time link | `shark expose demo 3000 -- npm run dev` | **beta** | | A database per Space | Postgres 16/17 or Redis 7, initialized once, woken on connect | `.shark/services.toml` | **beta** | | Secrets the agent cannot read | placeholders in the VM; the real value only toward the allowed hosts | `shark secrets set demo OPENAI_API_KEY --preset openai` | **GA** | | Git and GitHub without per-Space setup | connect once per account; every devbox Space gets `git`/`gh` and a commit identity | `shark github connect` | **beta** | | Move code between machines | copy files in and out with no clone and no running VM for reads | `shark cp -r ./repo demo:repo` | **GA** | | Long jobs and background processes | `--detach` an op and re-attach its output; `&` leaves a kept job of the Space | `shark run demo --detach -- 'make long'` | **GA** / **beta** | | CI shards and burst environments | one Space per pipeline, one fork per shard, one VM per shard command | see [the CI/Playwright note](use-cases/ci-playwright.md) | **planned** | | Sensitive industries, on-prem | not offered today; the node is designed to be movable | — | **not available** | ## Recipes ### A coding agent that survives your laptop ```sh shark login shark code --repo acme/api --agent claude --auth subscription ``` `Ctrl-\` or `~.` detaches and leaves the agent running; close the terminal, lose the network, or get a new laptop. From any machine: `shark attach acme-api` rejoins it (last output replayed), and `shark logs acme-api` shows what it printed while you were away. `shark stop acme-api` ends it. `Ctrl-C` interrupts the agent; use `--on-ctrlc detach` if you want `Ctrl-C` to detach too. See [shark code](shark-code.md). ### Tests, builds and a clean environment ```sh shark run demo -- 'npm ci && npm test' shark run demo --cwd web -- 'npm run build' ``` The command runs in a fresh VM; the files it changes stay in the Space. A dropped CLI connection resumes from the last event; an `op_id` makes a retry exactly-once. See [CLI](cli.md). ### Parallel attempts without cloning N times ```sh shark fork demo -n 8 # prints eight Space ids at the same commit shark run -- 'npx playwright test --shard=1/8' ``` Forks copy files in O(1), not histories of processes or service memory. Use one fork per attempt, per eval sample or per test shard; delete them when done. See [API](api.md) and [concepts](concepts.md). ### UI evidence: screenshots, flows, recordings ```sh # terminal 1 (bounded, as a kept job of the Space) (timeout 1800 npm run dev -- --port 5173 > dev.log 2>&1 &) # same or a later command shark screenshot http://127.0.0.1:5173/ shots/home.png --size 1280x800 shark screenshot http://127.0.0.1:5173/login shots/login-mobile.png --size 390x844 ``` `shark screenshot` waits for the URL, runs the image's headless Chromium once, validates the whole PNG and exits 0 only when the file is on disk. For a flow, use a Playwright 1.56 script in one command; the image ships Chromium and ffmpeg, so `playwright install` is not needed. A browser cannot stay open between commands; there is no desktop or computer use. The screenshot is a capture of the web page, not the operating system. See the [CLI](cli.md) and [shark code](shark-code.md). ### A dev server with a URL that sleeps ```sh shark expose demo 3000 --cwd app -- npm run dev shark open demo 3000 # one-time login link (private by default) ``` The process starts on the first connection and sleeps after 30 s idle; the next request wakes it. Your browser is the only place the URL has to be reachable. See [concepts](concepts.md) and [CLI](cli.md). ### A database that belongs to the Space ```toml # .shark/services.toml [postgres] version = "17" ``` The service is initialized once, restored from a snapshot, and sleeps when idle; connect from a command with the `DATABASE_URL` the Space exposes. Redis 7 works the same way. ### Secrets the agent cannot read ```sh shark secrets set demo OPENAI_API_KEY --preset openai shark secrets status demo --json ``` The command sees a placeholder; the node swaps in the real value only toward the hosts the secret is scoped to, and never writes it into the Space. Egress is a per-Space allowlist: a command cannot reach a host the Space does not allow. See [security](security.md) and [concepts](concepts.md). ### Move a repository between machines ```sh shark cp -r ./repo demo:repo shark cp demo:dist ./dist ``` Reads start no VM. A fork gives you a second checkout of the same files without a clone; `checkpoint` gives you a named commit to return to. ### Rejoin the agent from anywhere ```sh shark attach demo # the running agent, last output replayed shark attach demo --view # watch only shark logs demo # what it printed while you were away shark ls # working | waiting-for-you | idle | asleep ``` This is the mobile/laptop story: the terminal is a viewer; the agent is on the node. Closing the viewer does not stop the work. ## Cool combinations - **Agent swarm.** One Space for the task, `shark fork -n K` for K attempts, then compare the diffs and keep the best; the copies share the bytes they did not change. - **Proof attached to a pull request.** The agent starts the app, takes screenshots or a Playwright recording, and the files are in the Space for the PR. No browser install, no runner. - **A workspace that moves.** Files live in the Space, not on a machine; the next command may run anywhere the node places the Space. - **Burst data jobs.** A fresh VM per command for a one-off ETL or a scrape; the Space keeps the results and the scripts, the VM disappears. - **A platform, not a terminal.** The same Space/command/fork primitives are in the API and SDKs: give each customer or end user a Space, run their agent's commands, fork for parallel work, and bill per second of work. - **Secrets that stay out of the sandbox.** Give an agent a database or an API key without ever writing the value into the VM or the logs. ## What is beta, planned or not available | thing | status | note | |---|---|---| | `shark code` (the agent in the Space) | **beta** | Claude Code, Codex, OpenCode; detach and rejoin; sleeps when idle | | Browser screenshot | **beta** | one capture per command via `shark screenshot`; the image ships Chromium | | Scripted Playwright, video and trace | **beta** | use the image's Chromium/ffmpeg; pin Playwright 1.56 | | Desktop / computer use (OS GUI, native apps) | **not available** | no X server, no desktop; web-page automation only | | WebKit, Firefox or a non-Linux guest | **not available** in the image | installing one needs egress and is not verified | | Process memory across commands | **not available** | files persist; a RAM-resident REPL does not | | On-prem / BYOC | **planned** | not offered today; say so rather than promise it | | CI shards end to end | **planned** | the building blocks exist; the measured recipe does not | ## How an agent should choose 1. Need a shell command in a clean VM? `shark run`. 2. Need a coding agent that keeps working while the user is away? `shark code` (and `shark attach` to rejoin). 3. Need N independent tries? `shark fork`, then one `shark run` per copy. 4. Need a UI screenshot? `shark screenshot`; for a flow, a Playwright script in one command. 5. Need a URL? `shark expose` and `shark open`. 6. Need a database? add `.shark/services.toml` and connect with `DATABASE_URL`. 7. Need an API key? `shark secrets set`; never put the value in a prompt, a command or the Space. 8. Need files? `shark cp`. 9. Need GPUs, a desktop GUI, WebKit/Firefox, process memory across commands, or on-prem? Shark does not offer it today: say so plainly. ## Next - [Quickstart](quickstart.md): `echo hello` and a fork in under 10 minutes. - [Concepts](concepts.md): Space, lease, op, fork, services, secrets, egress. - [shark code](shark-code.md): the agent in the Space, detach/rejoin, conversation history. - [CLI](cli.md), [HTTP API](api.md), [Python SDK](sdk-python.md), [TypeScript SDK](sdk-typescript.md). - [Limits](limits.md) and [security](security.md): what is capped and how to report a vulnerability. - [CI and Playwright](use-cases/ci-playwright.md): the shard design note (planned). --- # Agents on Shark How to point a coding agent or your own agent loop at Shark. Pick by where the agent runs: | your agent | path | changes to the agent | status | |---|---|---|---| | Claude Code, Codex, Cursor or any agent with a shell, on your machine or server | **A. `shark run` as its command runner** | one instructions snippet | GA-path | | your own loop (any model, any framework) | **B. one tool backed by the SDK** | one tool definition | GA-path | | Claude Code, unmodified, running next to the Space | **C. the shell shim `shark-bash`** (+ `shark-rg`) | none: environment variables | beta, self-hosted | | a harness that cannot be intercepted | **D. `sharkfn` over MCP** | one MCP server | beta | | Claude Code, Codex or OpenCode, running **in the cloud** with its UI in your terminal | **[`shark code`](shark-code.md)** | none | beta, validated locally only | | LangChain, LangGraph, smolagents, Prefect | the SDK's integrations | a few lines | beta: [integrations.md](integrations.md) | In every path the model keeps thinking where it already does; only commands run in Shark, each in a fresh microVM next to the agent's persistent files and services. No VM exists for the Space while the model thinks. ## A. Claude Code, Codex, Cursor: `shark run` as the command runner 1. Install the CLI and log in once on the machine where the agent runs (`shark login --api-key -`; [quickstart.md](quickstart.md)). 2. Create a Space for the project: `shark create --name myproject`, and put the code in it: `shark cp -r . myproject:.` (or `git clone` inside it). 3. Paste the [AGENTS.md snippet](agents-md.md) into the project's instructions file: `AGENTS.md` (Codex, Cursor and others) or `CLAUDE.md` (Claude Code). It tells the agent to run builds, tests and installs with `shark run myproject -- '…'`, how exit codes work, and what not to do with keys. What this gives you: the agent's heavy and risky commands run in a microVM with the project's files, its own Postgres or Redis when declared, and the Space's egress policy; your machine runs only the agent. Files the agent edits locally are not synced into the Space by this path: have it write files through commands (`git apply`, heredocs), push changes with `shark cp`, or keep the whole project in the Space. Codex runs its shell commands through its own sandbox; `shark run` needs outbound HTTPS to `hel1.sharkmagic.si` from there (allow the host in the agent's network settings if it restricts network). ## B. Your own agent loop: one tool, the SDK, `op_id` = tool call id Expose one tool to the model and back it with `space.run`. Pass the tool call's id as `op_id`: if your loop crashes and replays a tool call, or retries a request, Shark returns the stored result instead of running the command twice. Tool definition (JSON Schema; adapt the envelope to your model provider's tool format): ```json { "name": "run", "description": "Run a bash command in the project's persistent Shark Space. Each command runs in a fresh Linux microVM; files persist between commands, shell state (cd, export) does not. Returns exit code, stdout and stderr.", "input_schema": { "type": "object", "properties": { "cmd": {"type": "string", "description": "bash command line, run from the Space root"}, "cwd": {"type": "string", "description": "optional directory relative to the Space root"}, "timeout_s": {"type": "number", "description": "optional; up to 900"} }, "required": ["cmd"] } } ``` Handler (Python): ```python from shark import Shark shark = Shark() # SHARK_API_KEY (an account key) from the environment space = shark.spaces.get("myproject") def run_tool(tool_call_id: str, cmd: str, cwd: str | None = None, timeout_s: float = 120) -> str: r = space.run(cmd, cwd=cwd, timeout=timeout_s, op_id=tool_call_id) out = f"exit: {r.exit}\n" if r.shark_status != "ok": out += f"shark_status: {r.shark_status}\n" return out + f"stdout:\n{r.stdout[-20000:]}\nstderr:\n{r.stderr[-10000:]}" ``` TypeScript is the same: `await space.run(cmd, { cwd, opId: toolCallId })`. Long commands: `space.stream(cmd)` yields output as it arrives; `space.start(cmd)` returns an `Op` you can re-attach to from another process with `space.op(op_id)`. Patterns that come free with Shark: - **One Space per agent, user or customer**, each with its own files and services; idle ones cost no compute. - **Fork per attempt.** `space.fork(count=3)` and run three fixes in parallel; keep the Space whose tests pass, delete the rest. - **Fork per eval or test shard.** One template Space, `fork(count=50)`, one shard per fork. - **Read files without a VM.** `space.files.read_text(path)` and `space.files.list(...)` read the committed tree directly; a "read file" tool costs no lease. ## C. Unmodified Claude Code: the shell shim `shark-bash`, self-hosted, beta. How the metal proofs ran real Claude Code with no change to the harness or its prompt. Claude Code runs on the Shark host; its file tools (Read, Edit, Write) work on the Space's host mount; every Bash command goes to a microVM. 1. Install `shark-bash` as a file named `bash` (Claude Code only accepts a shell whose path contains `bash` or `zsh`) and point the harness at it: `CLAUDE_CODE_SHELL=/opt/shark/bin/bash` (or `SHELL`). 2. Start Claude Code with its working directory inside the Space and the Space's host environment (`SHARK_W`, `SHARK_SP`, `SHARK_LEASE`, `SHARK_CTL`), which the host writes when it provisions the Space. 3. By default the shim runs pure reads (`cat`, `ls`, `grep`, `git status`, …) on the host mount and everything else in a microVM. `SHARK_STRICT=1` sends everything to a microVM and refuses (exit 126) what cannot go there; strict mode in the shim is opt-in because its own pre-registered test did not pass (P76). The HTTP API is always strict. Per command the shim snapshots the harness's edits into the Space, runs the command in a lease (output streamed while it runs), brings the lease's changes back into the harness's view and reports the final cwd. Ctrl-C and timeouts kill the VM. A second command while one runs waits `SHARK_BUSY_MS` (3,000) and then exits 125, unless `SHARK_CONCURRENCY=N|auto`. Measured on the AX41 (2026-11-05), for commands run through Shark: **91–96 % of a command's time is the user's command**; the rest is Shark's overhead (lease start, mount, commit). A lease itself costs **44 ms**. Status: used in the metal proofs with Claude Code 2.1.303 and 2.1.316. Provisioning a Space on a host is part of the proof kit, not a packaged installer: this path is for self-hosted nodes and design partners, not the hosted node. ### Grep and Glob: shark-rg Beta. Claude Code's Grep and Glob call `rg`. Over a lazily-loaded FUSE mount, walking a large tree is slow. `shark-rg` is an `rg`-compatible binary that answers those calls from an index of the Space's last commit plus what changed since, and runs the real `rg` with the same argv for anything it does not cover. The tool definitions the model sees are unchanged (byte-identical, so no token cost). ```sh export USE_BUILTIN_RIPGREP=0 # make Claude Code use the rg on PATH export PATH=/opt/shark/rg-shim:$PATH # a dir where shark-rg is installed as "rg" export SHARK_RG_MOUNTS=/path/to/space=/path/to/W/ctl.sock # mountpoint=control socket[,...] ``` `SHARK_RG_OFF=1` forces the real `rg`. Measured on the AX41 (2026-11-05): served from the index (`sharkfn`), Grep and Glob on a 100k-file tree are **300–600× faster than ripgrep over the FUSE mount**. Flag combinations that gave a different answer in testing go to the real `rg` by default (P95). ## D. MCP: sharkfn Beta. `sharkfn` serves Read, Write, Edit, Glob and Grep from a Space's committed tree, with no VM: ```sh sharkfn serve -repo R -mount /workspace -stdio -space # one Space over stdio sharkfn serve -repo R -mount /workspace -http 127.0.0.1:8792 # many Spaces: POST /mcp/ ``` Use it when the harness cannot be intercepted. MCP adds tool definitions to every model request, so it is not the default path; for Claude Code prefer `shark-rg` (no extra tokens). Self-hosted nodes only today. ## Keys and agents - Give the agent a key with only the scopes it needs. For path A, a default key (`spaces:read/write`, `exec`, `files:read/write`, `usage:read`) is enough; `secrets:write` and `network:write` belong to you, not the agent. - Never put the key in the prompt, the repo or the Space. The CLI reads `~/.shark/credentials`; the SDKs read `SHARK_API_KEY`. - Third-party credentials the agent's commands need (GitHub, a data API): store them as Space secrets with `expose: inject` ([concepts.md](concepts.md#secrets-inject-default-vs-exec)). The command sees a placeholder; the value never enters the VM, so the agent cannot print or exfiltrate it. - To hand a single Space to an agent, give it a Space-scoped key: `shark keys create --name agent-myproject --space myproject` (the secret is printed once; put it in the agent's secret store, not in its prompt). Agents under `shark code` already get a credential limited to their Space. --- # shark code: your coding agent in the cloud > **Status: beta, validated locally only.** Every check below passed on our local rig with a fake terminal agent and > a fake microVM (`proofs/p116-shark-code`, results `r3`). The real agents (versions below) were then run headless on > a local machine with Shark's shell and a fake model: each one started with Shark's configuration, sent its shell > command through Shark's shim and exited 0 (`proofs/p116-shark-code/HARNESSES.md`, results `smoke-r1`). The same > checks on real microVMs are pending. Until then, treat this page as the design you will get, not a measured promise. `shark code` runs Claude Code, Codex or OpenCode **inside your Space**, and shows its full terminal UI in your terminal: colors, resizing, Ctrl-C, everything. Close the laptop and the agent keeps working; attach again from any machine. No SSH, no keys to copy, no ports to open. ```text $ shark login $ shark secrets set myproj ANTHROPIC_API_KEY --preset anthropic --allow-egress Value for ANTHROPIC_API_KEY (input hidden): set ANTHROPIC_API_KEY (inject → api.anthropic.com, header:x-api-key) on myproj egress: profile anthropic-api on (shark egress show myproj) egress: now allows api.anthropic.com $ shark code --repo acme/api myproj [shark: cloning acme/api into myproj] shark: on branch shark/myproj of https://github.com/acme/api.git [shark: agent on myproj (writer); Ctrl-\ detaches, the agent keeps running] … the agent's UI … ^\ [shark: detached; the agent keeps running. shark attach myproj | shark logs myproj | shark stop myproj] $ shark ls tti-3f… ready myproj 2026-11-05T09:12:03Z claude waiting-for-you 4m0s $ shark attach myproj # from any machine; shows the last 64 KiB first $ shark attach myproj --view # watch without typing (any number of viewers, one keyboard) $ shark logs myproj # what it printed since you last left $ shark stop myproj $ shark run-agent --agent codex myproj "fix the failing test" # no terminal: streams output, exits with the agent's code ``` Without a Space name, `shark code` uses (or creates) a Space named after the repository (`--repo`), else after the current directory. Every verb takes `--help` with examples; `stop`, `logs` and `ls` take `--json`. ## Choosing the agent: `--agent`, `--remember`, `--forget` The first time you run `shark code` on a terminal without `--agent`, it asks which agent to run and how it logs in, then offers to remember the answer. Nothing is saved before the last answer; `q` or Ctrl-C cancels. ```text $ shark code myproj shark code: first run in myproj. Pick the coding agent (asked once if you remember it; scripts pass --agent). Which coding agent? › 1) Claude Code Anthropic's coding agent in the terminal (your Anthropic subscription login or an Anthropic API key) 2) Codex OpenAI's coding agent CLI (your OpenAI subscription login or an OpenAI API key) 3) OpenCode the open-source coding agent, any provider (its own login, or an Anthropic or OpenAI API key) How does Claude Code log in? › 1) subscription login log in inside the agent on its first start (your plan with the provider); … 2) API key a provider key (ANTHROPIC_API_KEY) stored as a Space or account secret; the VM only sees a placeholder ANTHROPIC_API_KEY is not set for myproj. Add it now? (API key only, and only when it is missing) › 1) add it to myproj the guided secrets setup (preset anthropic); the value is asked hidden 2) add it to the account every devbox Space of the account gets it 3) not now shark code stops here; add it later with shark secrets Remember Claude Code (api-key)? › 1) for this Space (myproj) shark code myproj starts it without asking 2) as the account's default every devbox Space without its own choice 3) no ask again next time [shark: created Space myproj (tti-…)] [shark: remembered claude (api-key) for myproj; change: shark code --agent A --remember space, forget: shark code --forget space] … the guided secrets setup (shark secrets setup), then the agent starts … ``` The key is never typed into `shark code` itself: the API-key choice runs the same guided setup as `shark secrets` (hidden value, preset for the provider's host). **Which agent runs, first match wins:** 1. `--agent A` (or `$SHARK_AGENT`) 2. the Space's remembered agent 3. the account's default (devbox Spaces only) 4. on a terminal: the first-run questions above 5. otherwise: exit 2, `harness_required`, with the command to run. **Without a terminal, or with `--json`, nothing is ever asked.** `--auth` follows the same order: the flag, else the remembered login when the agent is the remembered one, else the node's default (`api-key`). `--auth key` is short for `api-key`. ```bash shark code --agent list [--json] # the agents this node runs, and how each logs in shark code --agent codex --auth subscription --remember space myproj # remember for this Space (then starts it) shark code --agent claude --remember account myproj # the account's default for every devbox Space shark code --forget myproj # forget the Space's choice (--forget account | all) shark space set myproj --harness codex --harness-auth subscription # the same, without starting anything shark space defaults --harness claude # the account's default, without starting anything ``` The choice lives in the API, not on your laptop: the Space's in its settings (`PATCH /v1/spaces/{id}` `{"harness": {"agent", "auth"}}`, inherited by forks), the account's in `PUT /v1/space-defaults` `{"harness": …}`; `null` forgets. `GET /v1/spaces/{id}` shows `settings.harness` with its `source` (`space`, `account`, `none`). `GET /v1/agents` lists what the node's image has. SDK Spaces (`profile: "sdk"`) never get a harness: the account's default does not reach them and setting one is refused (`invalid_request`, `reason: sdk_profile`). ## Your repository: `--repo` ```bash shark code --repo acme/api # owner/name = github.com; or any https:// clone URL shark code --repo acme/api --branch develop # start from that branch (default: the repository's default) shark code --repo acme/api --work-branch fix-login # work on that branch (default: shark/; created if missing) ``` - The clone runs **once**, as one command in the Space (through its egress, like any command), into the Space's root. Run `shark code --repo …` again and the clone is kept as it is; a Space that already holds another repository is refused (`clone_failed`, with the fix). - Your `git config user.name` / `user.email` from this machine are set in the clone (if you have them). - **Git credentials never enter the VM.** With the Shark GitHub App connected (`shark github connect`, coming), the CLI stores a token for that one repository (contents and pull requests, ≤ 1 h, refreshed every 45 min while `shark code` runs) as the Space's `GITHUB_TOKEN` inject secret. Without it, a public clone works, and for pushes: `gh auth token | shark secrets set GITHUB_TOKEN --preset github`. In both cases the clone's git credential helper hands git the placeholder, and the egress proxy swaps in the token only toward `github.com` and `api.github.com`. ## Agents and versions | `--agent` | agent | version in the session VM image | |---|---|---| | `claude` | Claude Code | 2.1.318 | | `codex` | Codex CLI | 0.166.0 | | `opencode` | OpenCode | 1.19.5 (2.x is not supported yet) | The versions are pinned in the image (they do not self-update); a new version ships only after the same checks pass with it. There is no built-in default agent: see [Choosing the agent](#choosing-the-agent---agent---remember---forget). **First start.** With an API key, Claude Code skips its first-run setup and asks one question: whether you trust the folder (once per Space; it is remembered). Codex and OpenCode open ready. With `--auth subscription`, each agent shows its own login the first time. **`shark run-agent` runs the agent unattended**: it approves the agent's own permission prompts (Claude Code `--permission-mode bypassPermissions`, Codex without approvals, OpenCode `--auto`) and prints the agent's JSON events. Every command it runs still goes to its own microVM, as always. Interactive `shark code` keeps the agent's normal approval prompts. ## How it works - The agent runs in the Space's **session VM**. Its shell is Shark's shim: every command the agent runs (`npm test`, `git`, builds) runs in **its own fresh microVM** next to your files, exactly as with `shark run`. The session VM only holds the agent itself. - Your terminal talks to the agent over the same authenticated channel as the rest of the API (`shark-pty.v2`). A browser can attach the same way over WebSocket (protocol: `docs/spec/v1/shark-pty-v2.md`). - **States** in `shark ls`: `working`, `waiting-for-you` (it is asking you something or finished), `idle`, `asleep`. - **Notifications**: when the agent waits for you and nobody is attached, Shark can call your webhook, send a Telegram message or an email (configured per account; the message never contains anything from the screen). ## The agent and the terminal `shark code` starts the agent. `shark attach ` (when no agent runs, or with `--shell` or `-- cmd`) opens a **terminal**: a login shell (`bash -l`) in the session VM, as the Space's user, in the Space's root. It never starts an agent by itself. - **Commands you type in the terminal run in the session VM** (2 vCPUs, the Space's files), like on any machine. **The agent's commands go each to its own microVM.** Use `shark run` or the agent for heavy work. - **An agent you start by hand in the terminal** (`claude`, `codex`, `opencode` typed at the prompt, or after the agent exits) runs as under `shark code`: a small wrapper Shark puts first on the terminal's `PATH` gives it Shark's shim as its shell (each command in its own microVM), its config and login in the VM's memory-only directory (never in the Space's files, commits or forks), and Shark's context (`$SHARK_CONTEXT`, its instructions file). The terminal itself also carries the few variables only these agents read (`CLAUDE_CODE_SHELL`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME`), so Claude Code started by its full path routes too; your shell's `$SHELL`, `$HOME` and `XDG_*` stay yours (Shark's shim refuses interactive shells, so it is never your `$SHELL`). - A login made this way is saved to your encrypted store on the node only while a `shark code --auth subscription` session of the same agent runs in the Space (they share the directory); otherwise it stays in the VM's memory and is gone when the VM is replaced. For a login that lasts, use `shark code --auth subscription`. When a `shark code` agent stops, its directory is erased, including a login made by hand. - Not routed: an agent your login profile puts ahead of Shark's wrapper on `PATH` (Codex and OpenCode then run their commands in the session VM; Claude Code still routes and keeps its login out of the Space through the variables above), or an image without Shark's shim (the wrapper says so on start). ## Conversations: `--continue`, `--resume`, `--sessions` The agent's conversations are kept **in the Space**, so the next `shark code` can pick one up, after the agent exits, the VM sleeps or restarts, or the node restarts: ```text $ shark code --continue myproj # the last conversation (claude --continue) $ shark code --resume myproj # the agent's own picker (Claude Code, Codex) $ shark code --resume 0b4f3c2e-1d5a-4e6f-8a9b-0c1d2e3f4a5b myproj $ shark code --sessions myproj # past conversations: id, start, last activity, size ID STARTED LAST ACTIVITY SIZE 0b4f3c2e-1d5a-4e6f-8a9b-0c1d2e3f4a5b 2026-11-06 10:00 2026-11-06 11:42 1.2 MiB $ shark code --sessions myproj --json # {"sessions":[{"id","started","last_activity","bytes"}],"bytes",...} ``` | | `--continue` | `--resume [ID]` | `--sessions` | |---|---|---|---| | Claude Code | `claude --continue` | `claude --resume [ID]` | yes | | Codex | `codex resume --last` | `codex resume [ID]` | yes | | OpenCode | `opencode --continue` | `opencode --session ID` (an ID is required: error `resume_needs_id`) | one database: `opencode session list` in a terminal | - An id that is not a UUID (a Codex thread name): `--resume=NAME`. For an agent this `shark` does not know, the error is `resume_unsupported`; pass the agent's own flag after `--`. - Both flags apply when the agent **starts**. If it is already running, `shark code` joins it as it is and says so; `shark stop myproj` first to start it on another conversation. - **Where**: `/.shark/agents//` (Claude Code: `projects/` transcripts and the todo, task and plan files they refer to; Codex: `sessions/`; OpenCode: `opencode.db`), linked from the agent's config directory at every start, also for an agent you start by hand in a terminal. `.shark` is Shark's hidden directory of the Space (where its tool caches live): left out of the root's listings and of the files API, so `git add -A` does not pick it up. **A fork copies it**: the fork's agent can continue the same conversation. - **Never credentials**: only these paths are kept in the Space. The agent's login (`.credentials.json`, Codex's and OpenCode's `auth.json`) and settings stay in the VM's memory and your encrypted store, as before. Checked with the real agents: the API key they ran with is in none of the kept files. - **Size**: a long conversation is a few MB, and transcripts only grow. They count toward the Space's storage like any file; snapshots are deduplicated by content, so an append stores the new part, not a new copy. Claude Code deletes its own transcripts older than 30 days (its `cleanupPeriodDays` setting). Delete old ones from a terminal of the Space (`rm .shark/agents/claude/.claude/projects/*/ID.jsonl`); `--sessions` shows sizes. - The listing reads the Space's last commit (taken every 30 s while a terminal is attached, and when it leaves), and never shows what a conversation says. API: `GET /v1/spaces/{id}/agent/sessions?agent=claude` (scope `files:read`). - Conversations from before this version were in the VM's memory and are gone. If a start ends with `[shark: the agent exited (code 125) …]`: 125 is Shark's own code, not the agent's (the session VM, its terminal channel or the agent's start failed); the line says why. `shark logs myproj` shows what the agent printed, and `shark code --continue myproj` picks the conversation up again. ## What the agent knows The agent works as on your laptop, and it is told where it runs and what it can and cannot do, so it does not try things that cannot work here and tells you the right thing. Validated locally only (no model was run: `go test ./cmd/sharkfs -run TestAgentAware`, `./internal/codeagent`, `./cmd/shark -run TestCLIAgentContext`); that each real agent reads these files is checked on metal next. - **Instructions, written at every start** from the copy inside Shark (never a stale one): Claude Code gets the Shark skill in its skills folder and a short `CLAUDE.md` in its config folder; Codex its global `AGENTS.md`; OpenCode its global `AGENTS.md`. Nothing is written into your repository. They end with a block about this session: your Space's name, whether previews exist on this node, how you add a key or allow a host. - **`$SHARK_CONTEXT`** in the agent and in every command it runs: a small JSON document (mode `devbox`, Space, what works: background processes yes as jobs `bg-N` in their own VM (`shark ps` / `kill` / `logs`), browser inside one command yes (`shark screenshot` where the image has one), a browser kept open no, exposed processes per node; the egress policy; how you add a secret; where usage is; `profile` (`devbox` or `sdk`); `github` (`connected`, or `why` and the `fix` the user runs, e.g. `shark github connect`); for a `can` or `cli` that is off, why and the command that turns it on, e.g. `can_why.background_after_command`). `shark context --json` prints it. - **Your secrets and GitHub in the agent's commands.** Every command the agent runs gets the Space's secrets as its other commands do: placeholders `shk_ph_…` the egress swaps on the way to each secret's hosts, the Space's CA, and with GitHub connected (`shark github connect`, once per account) `GITHUB_TOKEN` and `GH_TOKEN` (one placeholder) and a git setup (credential helper for `https://github.com`, `git@github.com:` rewritten to https, the connection's noreply commit identity). A connection made while the agent runs reaches its commands within about 15 s; a secret set while it runs, at its next command. The agent's own process (not its commands) sees the secrets of its start. - **A `shark` CLI limited to this Space** (on the hosted node; a self-hosted node sets `-agent-api-url`): the agent's commands get a credential minted by the node for this Space only, and their egress allows the node's API host. It can read this Space's usage, ops and logs, list, read and stop its background jobs (`shark ps`, `shark logs bg-N`, `shark kill bg-N`), list secret names, read the egress policy, start detached commands and expose ports where the node offers them. It cannot read your account balance, top up, manage keys, set secrets, change egress, touch other Spaces, delete or fork, or attach to the agent itself. It is valid 2 h, re-minted every hour while the agent runs, revoked when the agent stops, and never placed in the agent's own environment or instructions. A node restart invalidates it until the next start. - **What it tells you.** "How much credit is left?": it reads this Space's usage and tells you to run `shark balance`. It needs an API key: it asks you to run `shark secrets set NAME --preset …` (hidden prompt), never for the key in chat. A blocked host: it reads `shark egress denials ` and relays `shark egress use +PROFILE` (the profile that has the host), or `shark egress add HOST` when none has it. A server that should stay up: an exposed process where the node has them; otherwise it says so. Shark's own notices in a command's output start with `shark:` ([errors.md](errors.md#lines-shark-adds-to-a-commands-output)). ## Egress: nothing to configure The agent runs in the Space's session VM under the Space's egress policy, and `shark code --agent X` turns on X's egress profile by itself: `claude-code` (the Anthropic API, the Claude login and token refresh, the model catalog), `codex` (the OpenAI API, the ChatGPT login and its backend) or `opencode` (its model catalog `models.opencode.ai`, which OpenCode fetches at every start, and its own Zen / Go service). With an API key, the key's provider profile goes on too (`anthropic-api`, `openai-api`), and `shark-docs` (these docs, `sharkmagic.si`) when the Space's list lacks it. Devbox Spaces already have `essentials` (package registries, GitHub, GitLab, Ubuntu mirrors, Docker Hub, Shark's docs). They show in `shark egress show ` as "added by shark itself"; `shark egress use -opencode` removes one, `PATCH …/egress {"auto_profiles": false}` stops it. Another provider (OpenRouter, Gemini) is one more profile: `shark egress use +openrouter`. The session VM takes a new policy at its next start (an idle one is restarted when the agent starts). See what was refused with `shark egress denials `. Each profile's hosts, why each is there and how it was checked: `shark egress profiles opencode`. ## Model credentials: two ways ### 1. API key (default, recommended) Store your provider key as an **inject** secret of the Space. The value is read from a hidden prompt or stdin, never from the command line: ```bash shark secrets set myproj ANTHROPIC_API_KEY --preset anthropic --allow-egress shark code myproj ``` (For Codex: `shark secrets set myproj OPENAI_API_KEY --preset openai --allow-egress`.) `--allow-egress` adds the provider's egress profile (`anthropic-api`, `openai-api`) to the Space. Without the key, `shark code` stops before starting the agent and prints this exact command (`missing_key`). The agent sees a placeholder (`shk_ph_…`), never the key. Shark's egress proxy swaps the placeholder for the real key only on requests to the provider's API host. **The key never enters the VM**: code the agent runs (a malicious package, a prompt-injected command) cannot read it. A key stored as plain environment is removed from the agent's environment on purpose. ### 2. Your own subscription ```bash shark code --auth subscription myproj # then log in inside the agent's UI, as on your laptop ``` - **Use of a subscription in a cloud machine is at your discretion and under your provider's terms.** Check them before you do it; Shark does not decide this for you. - With a subscription, **the login token does live inside the VM while the agent runs** (unlike API-key mode), so code the agent runs could read it. Prefer API-key mode for untrusted repositories. - Where it is kept: the agent's login files are stored **encrypted, per account**, on the node, outside your Space. They are written to a memory-only directory of the VM when the agent starts, saved back when they change, and erased when the agent stops. They are never part of the Space's files, its commits, its backups or its forks. - While the VM is asleep, its memory image (including the token) is kept on the node's local disk; it is not uploaded. ## Billing - While the agent runs, the session VM is billed as an **awake service** (at a fraction of the command rate; see pricing). Commands the agent runs are billed as usual, per command. - After the agent has been **waiting for you** for 10 minutes (configurable), the session VM goes to sleep and costs nothing but storage. Your next key, or `shark attach`, wakes it. - "The VM sleeps while the model thinks" is **not** claimed: it is an open measurement. ## Limits and what is not validated yet - Real agents in real microVMs are validated on metal next. Locally, with the real agents: Claude Code's state hooks work, and all three send their shell commands through Shark's shim. - Codex's own sandbox is off in the session VM (each command's microVM is the sandbox). Codex finds its shell by name rather than `$SHELL`, so Shark puts its shim first on Codex's `PATH`; this relies on the agent's user having no login shell of its own in the VM, which is how Shark runs it. - The agent's state is detected from its screen (and from its hooks where it has them); a silent long-running tool call can read as `waiting-for-you`. --- # AGENTS.md / CLAUDE.md snippet Instructions that tell a coding agent how commands run in Shark. Two forms, one source: - **Claude Code skill** (full version, loads itself when the agent works in a Space or you mention Shark): `shark skills install` copies it to `~/.claude/skills/shark`. - **Snippet** for `AGENTS.md` (Codex, OpenCode, Gemini CLI, Cursor and most agents) or `CLAUDE.md`: the block below. `shark skills install --agents-md AGENTS.md --space myproject` writes it into the file between `` and `` markers (idempotent; `--dry-run` shows what it would do), and `shark skills snippet --space myproject` prints it. The canonical text lives in the Shark repository under `skills/shark/` (`SKILL.md`, `reference/`, `AGENTS-snippet.md`); this page copies the snippet verbatim and a test fails if the two differ. Replace `myproject` with your Space's name (`shark create --name myproject`). It assumes the `shark` CLI is installed and logged in where the agent runs ([quickstart.md](quickstart.md)), or that the agent runs inside the Space ([shark-code.md](shark-code.md)). ````markdown ## Shark: how commands run here Builds, tests, installs, scripts and servers run in the Shark Space `myproject`. If `SHARK_SPACE` or `SHARK_SP` is set, your shell already runs there (`shark code`): run commands normally. Otherwise run each one as `shark run myproject -- ''` (quote the whole line; `--cwd DIR` for a subdirectory). - Check, don't assume: `echo "$SHARK_CONTEXT"` or `shark context --json` says where you run (`SHARK_MODE` devbox|shim|api), what works here (background processes, exposed processes, browser, egress) and what is the user's to do (credit, keys, hosts). Lines starting `shark:` on stderr are Shark's: follow their next step. - Each command runs in a fresh Linux microVM. Files persist between commands; `cd` and `export` do not. Put variables inline (`FOO=1 npm test`) and keep the cwd inside the Space. Python: system pip is refused; use a venv in the Space (`python3 -m venv .venv && .venv/bin/pip install …`, then `.venv/bin/python`). - Processes started with `&`: where `can.background_after_command` is true (hosted `shark code`) they keep running as a job `bg-N` in their own VM, reachable at `127.0.0.1:` from later commands but invisible to `ps`/`pkill`: `shark ps`, `shark logs bg-N`, `shark kill bg-N`. Bound them and wait in the same command: `(timeout 1800 npm run dev > dev.log 2>&1 &); for i in $(seq 120); do curl -sf localhost:3000 >/dev/null && break; sleep 0.5; done`. Elsewhere they die with the command: start and probe in one command (`npm run dev & sleep 5; curl -sf localhost:3000; kill $!`). - Postgres/Redis: commit `.shark/services.toml` (`[postgres]`, `version = "17"`); commands get `DATABASE_URL`. A server the user opens in their browser: `shark expose myproject 3000 -- npm run dev` and `shark open myproject 3000` (a one-time 60 s login link; private by default; `422 unsupported` on a node without previews). Previews are billed while awake even with auto-sleep: tell the user, and `shark expose myproject --rm 3000` when nobody is looking at it. - Long jobs: `shark run --detach myproject -- ''` prints an op id; `shark logs myproject ` follows it. Sync API calls stop at 900 s, streamed/async ones at 2 h. - Parallel attempts: `shark fork myproject -n 3` (O(1) copies; not secrets, processes or database data); delete the forks you do not keep (`shark rm `). Inside one command, use the VM's cores (`make -j8`, `pytest -n 8`). - Headless browser: only inside one command (launch, screenshot to a file, close). `shark screenshot http://127.0.0.1:3000/ out.png` uses the image's browser where it has one (`$CHROME_PATH`; Playwright 1.56 finds it with no install). No browser stays open between commands; no desktop/computer use. - Reads are free: read files with your file tools or `shark cp myproject:path -`, not `cat` in a VM. Nothing is billed while you think; do not poll with `sleep` in a VM; remove exposed servers and forks you no longer need. - Exit 137 is usually out of memory (4 GiB VM): lower parallelism or split the job. `shark: conflict` → run it again; `shark: infra_error` or exit 125 → run the same command again. `402` → stop and tell the user (credits); `429 lease_cap_reached` and `503 out_of_capacity` → the command did not start: wait `Retry-After` and retry; `422 unsupported` → not on this node, do not retry. - Never print, paste or commit the Shark API key or any secret, and never put one in the Space. Third-party tokens are Space secrets with `expose: inject`: commands see a placeholder (`shk_ph_…`), which is expected; do not override the CA bundle variables or disable TLS checks. Blocked host → tell the user; they change the Space's egress policy. - Never quote Shark prices, limits or numbers from memory. Check: `shark whoami`, `GET /v1/quotas`, `GET /v1/capabilities`. Docs for agents: https://sharkmagic.si/docs/llms.txt ```` ## Variant: SDK-based agent If your agent calls Shark through the SDK instead of the CLI, keep the same rules and replace the command lines with: ````markdown - Run commands with `space.run(cmd, op_id=)`; pass the tool call id so a replayed call never runs twice. - A non-zero `exit` is a normal result. `shark_status` other than `ok` is Shark's: `conflict` → re-run with a new op_id; `infra_error` → retry with the same op_id; `timeout` → raise `timeout` or use `space.stream`. ```` ## Why each line is there | line | reason | |---|---| | quote the whole command | `shark run` joins its arguments into one bash line; an unquoted `&&` or `>` would run on the local machine | | `SHARK_SPACE` / `SHARK_SP` | inside `shark code` (or the shell shim) the shell is already routed to Shark; prefixing `shark run` there would fail or double-route ([agents.md](agents.md)) | | files persist; `&` is a job in its own VM, or dies | each command is a new microVM; kept jobs are a node setting, on in hosted `shark code` ([concepts.md](concepts.md#lease-one-command-one-microvm), [background processes](concepts.md#background-processes)) | | venv | the image's system Python refuses `pip install`; a venv in the Space persists for later commands and forks ([concepts.md](concepts.md#lease-one-command-one-microvm)) | | services.toml / expose | the homes for long-running things, each in its own VM that sleeps ([concepts.md](concepts.md#services-that-sleep), [exposed processes](concepts.md#exposed-processes-and-preview-urls)) | | detach, 900 s / 2 h | sync and streamed limits ([limits.md](limits.md#commands)) | | fork | O(1) on files, made for parallel attempts ([concepts.md](concepts.md#fork)) | | browser in one command | nothing survives the command; browsers in the hosted image are not verified ([integrations.md](integrations.md#heavy-jobs-one-command-does-the-whole-job)) | | reads are free | file reads are served from the committed tree without a VM ([billing.md](billing.md#what-is-metered)) | | 137 / conflict / 125 / 402 / 429 / 503 / 422 | [errors.md](errors.md) and `shark_status` ([concepts.md](concepts.md#lease-one-command-one-microvm)) | | keys, placeholders, CA bundle | an agent that sees a key can leak it; `inject` keeps third-party values out of the VM and needs the command's CA bundle ([security.md](security.md)) | | never quote numbers from memory | limits and prices change; the API is the source ([limits.md](limits.md)) | --- # 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 `. 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": ""}` 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": "", "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/.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: ` 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://-.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: {"" \| "=": {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 [--purge]` | `space.delete()` | `space.delete()` | | `POST /spaces/{id}/exec` (stream) | `shark run -- ''` | `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 `, `shark logs ` | `space.ops()`, `space.op(id).events()` | `space.ops()`, `(await space.op(id)).events()` | | `POST /spaces/{id}/fork` | `shark fork [-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.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 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 \| ] [--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.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.settings()`, `space.update(…)` | `space.settings()`, `space.update(…)` | | `PUT`/`GET`/`PATCH`/`DELETE /spaces/{id}/ports/{port}`, `…/sleep`, `…/logs`, `…/url` | `shark expose PORT -- cmd`, `shark expose --ls\|--public PORT on\|off\|--sleep PORT\|--logs PORT\|--rm PORT`, `shark open 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` | — | — | --- # CLI: `shark` One static binary for you and your agents (Linux, macOS; amd64, arm64). Install: ```sh curl -fsSL https://dl.sharkmagic.si/install.sh | sh ``` It verifies the binary's sha256 against the release's `SHA256SUMS` before installing (details and the manual path: [quickstart.md](quickstart.md#0-what-you-need)). `shark version [--json]` prints the build (`version`, `commit`, `os`, `arch`); `shark update [--json]` says whether a newer release exists (`update_available`, `latest`) and changes nothing; `shark update --install` downloads it, checks the same checksum and replaces the binary in place (error codes `checksum_mismatch`, `update_check_failed`, `update_not_writable`). `SHARK_RELEASES_URL` points both at a mirror with the same layout. ## Cheat sheet ```sh shark login # browser login with a one-time code shark login --api-key - # an account API key (stdin or a hidden prompt; CI: < keyfile) shark whoami # tenant (your account), credential, scopes shark keys create --name ci > ci.key # an API key for CI (secret shown once) shark org ls ; shark org use acme # personal workspace + teams; switch this login shark org invite acme --email ana@example.com # invite someone into a team (owner/admin) shark code --repo acme/api # a coding agent in the cloud on that repo (shark-code.md) shark secrets set demo ANTHROPIC_API_KEY --preset anthropic --allow-egress # value from a hidden prompt shark egress add demo api.stripe.com # let the Space's commands reach one more host shark quotas # your limits, current use, limits class, node admission shark quotas labels set end_user --awake 3 # per end user: 3 Spaces awake (platforms) shark create --name demo # prints the Space id shark run demo -- 'npm ci && npm test' # stream output, exit with the command's code shark run demo --cwd web -- 'npm run build' # run in a subdirectory shark run demo --detach -- 'make long-job' # start it, print the op id, return shark logs demo # re-attach to an op's output from anywhere shark ops demo # recent commands: op id, state, exit, shark_status shark ps demo # background jobs (bg-N); shark kill bg-1 demo stops one shark fork demo -n 3 # three O(1) copies at the same commit shark cp -r ./repo demo:repo # upload a directory (one commit) shark cp demo:out/report.json . # download a file (no VM starts) shark files ls demo [path] # list files shark hibernate demo ; shark wake demo # sleep now / wake now shark usage --by space # metered usage shark ls ; shark rm demo # list, delete ``` `` is always a Space id or its name. Every verb has `--help` with examples (`shark secrets --help`), and `shark --help` lists them all. ## Output, errors and exit codes Made to be driven by people and by agents alike: - **`--json`** on every verb that prints a result (`ls`, `whoami`, `create`, `rm`, `fork`, `ops`, `hibernate`, `wake`, `quotas`, `usage`, `balance`, `topup`, `secrets`, `egress`, `github`, `stop`, `logs `, `run --detach`, `login`, `keys`, `org`, `logins`): stdout is then **exactly one JSON document**, the result or `{"error": {"code", "message", "hint", "status"}}`. Progress lines (`[shark: …]`) always go to stderr. - **Errors** in text mode: `shark: ` and, when there is a next step, ` next: ` on stderr. The `code` is stable (`not_logged_in`, `usage`, `not_found`, `missing_key`, `credits_exhausted`, `insufficient_scope`, `quota_exceeded`, `out_of_capacity`, `github_not_connected`, `clone_failed`, `not_a_terminal`, `confirmation_required`, `harness_required`, `cancelled`, …; API codes as in [errors.md](errors.md)). Never a stack trace. - **Exit codes**: `0` done; `1` the API refused or failed (the code says why); `2` usage error, or not configured / not logged in; `125` Shark could not report a result (`run`, `logs`), or the agent's session VM, its terminal channel or the agent's start failed (`code`, `attach`: the `[shark: … (code 125) …]` line says which); otherwise the command's own exit code (`run`, `logs `, `run-agent`). - **No prompts you cannot avoid**: a secret value comes from stdin when it is piped (a hidden prompt only on a terminal); `--no-browser` / `--print` where a browser would open; every confirmation has `--yes`. When stdin is not a terminal nothing asks: the command fails with the flag form in `next:` (`not_a_terminal`, `confirmation_required`). A question cancelled with q / Ctrl-C exits `130` (`cancelled`), nothing changed. - **Colors** only when stderr is a terminal; `NO_COLOR=1` (or `TERM=dumb`) turns them off. Tables have a header only on a terminal, so scripts can split lines on tabs. ## Configuration | source | keys | |---|---| | environment | `SHARK_API_KEY` (an account key), `SHARK_CP` (account service URL; default `https://api.sharkmagic.si`), `SHARK_API` (a self-hosted node's URL: **direct mode**, below), `SHARK_API_CA` (a PEM to trust, for private nodes) | | `~/.shark/credentials` | written by `shark login` (mode 0600; ignored if group- or world-readable) | | `~/.shark/config` | `KEY=VALUE` lines, same keys (mode 0600) | The environment wins over the files. URLs must be `https://` (plain `http://` only to a loopback address, so a key never travels in clear). **Two modes.** With an account key or a `shark login` (the hosted service), the CLI talks to the account service: it exchanges the key there for 5-minute node tokens and sends each call to the node that holds the Space. The key never reaches a node. **Direct mode** (`SHARK_API` set, or `shark login --api-key - --api URL`) sends the key to that one node as is: it is for a self-hosted node and that node's own keys. An account key in direct mode answers `401 unauthenticated`, and the account verbs (`balance`, `keys`, `org`, …) answer `needs_control_plane`. To leave direct mode: `unset SHARK_API` and `shark login --api-key -` again (the login rewrites `~/.shark/credentials`). ## Account | command | what | API | |---|---|---| | `shark login --api-key [KEY\|-] [--cp URL]` | an **account API key** (servers, CI, agents): checked against the account service (default `https://api.sharkmagic.si`) and stored in `~/.shark/credentials` (0600). `-` (or nothing) reads stdin or a hidden prompt, so the key stays out of argv and shell history. Exchanged for node tokens, never sent to a node | `GET /v1/me`, `POST /v1/token` | | `shark login [--cp URL] [--no-browser]` | browser login with a one-time code (RFC 8628): the CLI prints a link and a code, you confirm it on `https://api.sharkmagic.si/activate` (not signed in there: it sends you to its sign-in page, GitHub / Google / e-mail + password, and back to the code). The CLI then says `Logged in`, or exits 1 with `the request was denied in the browser` or `the code expired; run shark login again` (10 minutes). No browser at hand (a server, an agent): `shark login --api-key -` with an account key instead. Stores a refresh token (0600); every node call then carries a 5-minute token the CLI mints and refreshes itself. `--no-browser` only prints the link | `POST /v1/auth/device`, `/v1/auth/token` | | `shark login --api-key [KEY\|-] --api URL` | **direct mode**, for a self-hosted node: that node's own key, checked against its `/v1/me` and stored with the URL. Not for account keys | `GET /v1/me` (node) | | `shark logout` | revokes the login session (if any) and deletes the credentials and token cache | `POST /v1/auth/revoke` | | `shark whoami [--json]` | who this CLI acts as. With the account service (login or account key), `--json` is `GET /v1/me` of the account service plus `control_plane`: `tenant` (your account), `kind` (`session` or `api_key`), `credential` (its id), `scopes`, `space` (a Space-bound key, else `null`), `expires_at`, `organization` `{id, name, slug, personal}`, `billing_mode`, `suspended`. In direct mode (a node key, `SHARK_API`): `tenant` and `credential` `{kind, id, scopes}` | `GET /v1/me` | | `shark quotas [--json]` | every limit that applies to you with current use, your `limits_class` (`new`, `standard`, `partner`, …), `raise` (how to get more), per-label limit rows (`scope: label`), and `node_admission` (is the node admitting new work: `state` `open`/`closed`, `reason`) | `GET /v1/quotas` | | `shark quotas labels [ls] [--json]` / `set [--awake N] [--spaces N]` / `rm ` / `clear` | per-label limits for a platform's end users (`set end_user --awake 3`: each `end_user` value gets 3 awake; `end_user=acme` caps that one value). Account service only (`needs_control_plane` in direct mode); a bad value exits 2 before any call ([limits.md](limits.md#per-label-limits)) | `GET /v1/limits`, `PATCH`/`PUT /v1/limits/labels` | | `shark balance [--json]` / `shark usage [--by day\|space\|kind] [--json]` | prepaid balance; rated usage (account service; against a node directly: the node's meter) | `GET /v1/balance`, `/v1/usage` | | `shark topup [USD] [--print]` | opens a Stripe Checkout page for prepaid credit (default $5); the credit shows once the payment is confirmed. At zero balance commands get `402` and files stay readable | `POST /v1/checkout` | | `shark plan [status\|subscribe [--print]\|cancel] [--json]` | the monthly plan: a usage allowance each month, spent before prepaid credit, gone at the end of each period (no roll-over); `subscribe` opens Stripe Checkout (or resumes a pending cancel); `cancel` ends it at the period end | `GET /v1/plan`, `POST /v1/plan/subscribe`, `POST /v1/plan/cancel` | | `shark invoices [--json]` | a one-time link to the Stripe customer portal (invoices, receipts, payment methods); `409 no_billing_customer` before the first payment | `POST /v1/billing/portal` | | `shark new [--name N]` | same as `create`; with the account service the Space goes to the node placement picks | `POST /v1/placement` | | `shark keys ls\|create\|rotate\|rm [--json]` | API keys of the org the CLI acts in. `create --name N [--scopes "a b"] [--space SP]` prints the secret **once** (alone on stdout; `--json`: the `token` field); `rotate [--overlap 1h]` issues a new key and keeps the old one for the overlap (default 1 day); `rm ` revokes. Needs your login as owner/admin of the org, or a key with `admin` | `GET`/`POST /v1/keys`, `POST /v1/keys/{id}/rotate`, `DELETE /v1/keys/{id}` | | `shark org ls\|use\|create\|invite [--json]` | every account starts with a **personal workspace** (just you; nothing to set up); teams are opt-in. `ls` marks the one this login acts in; `use ` switches this login (keys, balance, usage and Spaces follow); `create --name N [--slug S]` makes a team you own; `invite --email E [--role member\|admin]` invites someone into a team (owner/admin; they accept in the dashboard; the invitation lasts 48 h; the personal workspace takes no other members: `403 personal_workspace`). An API key belongs to one org: `use`, `create` and `invite` need `shark login` (`403 user_credential_required`) | `GET`/`POST /v1/orgs`, `POST /v1/orgs/{org}/use`, `POST /v1/orgs/{org}/invitations` | | `shark logins ls\|rm [--json]` | your CLI logins (each `shark login`): device, org, last use; `rm` ends one. (`shark sessions` is agent-session telemetry) | `GET /v1/logins`, `DELETE /v1/logins/{id}` | | `shark sessions [--kind agent\|api\|shim] [--harness H] [--space ID] [--days N]`, `sessions show `, `sessions summary` | your agent sessions: turns, tool calls by type, MCP servers, commands by class, thinking vs executing time, errors by cause. Metadata only: never a prompt, command text, file or argument | `GET /v1/sessions`, `/v1/sessions/{id}`, `/v1/telemetry/summary` | | `shark context [--json]` | inside a Space: where this shell runs and what works here (read from `$SHARK_CONTEXT`; no network, no key) | — | ## Spaces | command | what | API | |---|---|---| | `shark create [--name N] [--label k=v]... [--idempotency-key K] [--profile devbox\|sdk] [--account-secrets all\|none\|NAME,NAME] [--github on\|off] [--git-name N] [--git-email E] [--keep-background on\|off] [--keep-awake on\|off] [--space-cli on\|off\|SCOPE,SCOPE]` | prints the new id; retried safely (an idempotency key is generated if you give none). The CLI makes **devbox** Spaces (the account's secrets and GitHub, background jobs, the Space CLI); `--profile sdk` makes one as the SDKs do (nothing of the account unless asked: [api.md](api.md#sdk-spaces-and-devbox-spaces-profile)) | `POST /v1/spaces` | | `shark ls [--label k:v]` | `id state name created_at`, all pages | `GET /v1/spaces` | | `shark rm [--purge]` | delete; `--purge` also deletes the durable copy | `DELETE /v1/spaces/{id}` | | `shark fork [-n N] [--account-secrets …] [--github on\|off] [--keep-background on\|off] [--keep-awake on\|off] [--space-cli …]` | prints one id per fork (N ≤ 50); a fork keeps its source's profile and choices unless a flag says otherwise | `POST /v1/spaces/{id}/fork` | | `shark hibernate ` / `shark wake ` | ` hibernated` / ` ready (woke in X ms)` | `POST …/hibernate`, `…/wake` | | `shark checkpoint ` | waits until HEAD is in the durable store; prints JSON | `POST …/checkpoint` | | `shark status ` | replication status JSON | `GET …/durable` | | `shark space get [--json]` | every setting of the Space in one view: name, labels, env, egress (policy and effective hosts), secrets (names, exposure, hosts), exposed ports (public or not), hibernation, durability, agent, limits | `GET /v1/spaces/{id}` (`settings`) | | `shark space set [--name N] [--label k=v] [--unlabel k] [--env K=V] [--unenv K] [--idle 30m\|off\|default] [--allow H] [--disallow H] [--public PORT=on\|off] [--account-secrets all\|none\|NAME,NAME\|default] [--github on\|off\|default] [--git-name N] [--git-email E] [--keep-background on\|off\|default] [--keep-awake on\|off\|default] [--space-cli on\|off\|default\|SCOPE,SCOPE] [--harness AGENT\|default [--harness-auth subscription\|api-key]]` | change several settings in one request; every field is checked first and a refused one changes nothing. `--allow`/`--disallow` need `network:write`, `--public`, `--keep-*` and `--space-cli` need `exec`, `--account-secrets`/`--github`/`--git-*` need `secrets:write`. `default` = the profile's. `--harness`: the agent `shark code` starts without asking (devbox Spaces only) | `PATCH /v1/spaces/{id}` | | `shark space defaults [--account-secrets all\|none\|default] [--github on\|off\|default] [--harness AGENT\|default [--harness-auth M]] [--json]` | the account's defaults for every Space that does not set them (then the profile's); `--harness`: the account's default agent for devbox Spaces; no flag: show them | `GET`/`PUT /v1/space-defaults` | ## Commands | command | what | API | |---|---|---| | `shark run [--op-id ID] [--cwd DIR] [--detach] [--keep-background\|--no-keep-background] -- ` | runs the command line in a fresh microVM; streams stdout/stderr live; exits with the command's exit code. `--keep-background`: what it leaves running (`cmd &`) stays as a job `bg-N`; `--no-keep-background`: it ends with the command (unset: the Space's `keep_background`; sdk off, devbox on). A dropped connection resumes from the last event; the generated `op_id` makes retries exactly-once. Ctrl-C signals the command (`INT`), not only the CLI. `--detach` prints the op id and returns. Timeout: 2 h | `POST …/exec` (SSE), `…/ops/{op_id}/signal` | | `shark ops ` | `op_id state exit shark_status created_at`, newest first | `GET …/ops` | | `shark logs ` | the op's full output (live if it still runs); exits with its code | `GET …/ops/{op_id}/events` | | `shark ps [] [--json]` (alias `processes`) | background jobs a command left running: `id (bg-N) state ports started op cmd` (the command redacted and cut as op logs show it). Inside a Shark session the Space is `$SHARK_SPACE` | `GET …/processes` | | `shark kill bg-N []` | stops the job (TERM); what it wrote lands in the Space. `shark service rm bg-N` (what older job lines said) does the same | `POST …/processes/{pid}/signal` | | `shark logs bg-N [] [--tail BYTES] [-f]` | the job's output as committed (grows at its snapshots and end); `-f` follows it until the job ends | `GET …/processes/{pid}/logs` | | `shark screenshot [out.png] [--size WxH] [--wait S] [--budget-ms N] [--json]` | local, no API: waits for the URL, runs the headless browser once (`$CHROME_PATH`, else chrome-headless-shell/chromium on `PATH`), writes the PNG at the path given (relative to the command's cwd) and checks it whole (IEND, the asked size, synced); exit 0 only then. Errors `no_browser` (127), `not_ready`, `screenshot_failed` (no file left), `bad_path` (2: directory missing) | — | `shark run` joins its arguments with spaces into one bash command line: quote the whole thing, `shark run demo -- 'cd web && npm test'`. Exit codes: the command's own code when it ran; `1` when the API refused it and nothing ran (for example the Space does not exist; the `next:` line says what to do); `125` when Shark could not report a result (the op goes on: `shark logs `); `2` for usage or configuration errors. When `shark_status` is not `ok` (conflict, timeout, infra error) it is printed on stderr. ## Files | command | what | API | |---|---|---| | `shark cp :` | upload a file (a path ending in `/` keeps the file name) | `PUT …/files` | | `shark cp -r :` | upload a directory as one commit (skips `.git/`) | `POST …/files/archive` | | `shark cp : \|-` | download a file (`-` = stdout) | `GET …/files` | | `shark cp -r : ` | download a directory | `GET …/files/archive` | | `shark files ls [path]` | `type size path` | `GET …/tree` | | `shark files get [local]` / `put ` | one file | `GET` / `PUT …/files` | | `shark files rm [-r]` / `mv ` | remove / move (one commit each) | `DELETE …/files`, `POST …/files/move` | | `shark files push [remote]` / `pull ` | directories | archive routes | Reads never start a VM and never wake a hibernated Space. ## Live: exposed processes, previews, terminal **`expose` and `open` are live on the hosted node** (P130 flow PASS on metal, 2026-11-07): each exposed process runs in its own microVM, private by default, sleeps after its idle time and wakes on the next request. It is billed while awake, by size; asleep it is not ([billing.md](billing.md#what-is-metered)): remove the ones you no longer need with `--rm`. **`shark connect` is coming**: it passed its local gates (P112) but is not yet verified on the hosted node. On a node without exposed processes every verb here prints the node's `422 unsupported`. | command | what | API | |---|---|---| | `shark connect ` | (coming) interactive shell: each line runs as one op; `cd`, `export`/`unset` and `$?` carry over; Ctrl-C interrupts; `exit` or Ctrl-D leaves. No TTY inside the command (use it for shells, not full-screen programs) | WebSocket `GET …/terminal` | | `shark expose [--public] [--idle S] [--cwd DIR] [-e K=V]... -- ` | run `cmd` as a sleeping server of the Space on `port`: starts on the first connection, sleeps after `S` s idle (default 30), sees every commit | `PUT …/ports/{port}` | | `shark expose --ls \| --rm PORT \| --logs PORT \| --sleep PORT \| --public PORT on\|off` | list, remove (stops it and revokes every preview cookie), logs, put to sleep now (wakes on the next request), toggle public | `GET`/`DELETE`/`POST`/`PATCH …/ports…` | | `shark expose --ls --all [--awake] [--json]` | every exposed process of the account, awake first: Space, port, state, public, last request, URL, and how many are awake (billed now); `--awake` only those running or starting. `--json`: `{"data": [{space, space_name, port, state, awake, public, url, last_request_at, awake_since, …}], "awake": N}` | `GET /v1/ports` | | `shark open [--print] [--path /p]` | mints a one-time login link (60 s) to `https://-.preview.sharkmagic.si` and opens your browser (`--print`: only print it) | `POST …/ports/{port}/url` | Expected (the output shape; the Space id is an example): ```text $ shark expose demo 3000 --cwd app -- node server.js exposed node server.js on port 3000 (declared); it starts on the first connection and sleeps after 30s idle preview: https://tti-ec8e46415dcc2dc3182daae2bc8a8aa2-3000.preview.sharkmagic.si/ private: open it with `shark open demo 3000` ``` ## Agent instructions: `shark skills` Local files only (no key, no network). The text ships inside the CLI; its source is `skills/shark/` in the Shark repository ([agents-md.md](agents-md.md)). | command | what | |---|---| | `shark skills install` | writes the Claude Code skill to `$CLAUDE_CONFIG_DIR/skills/shark` (else `~/.claude/skills/shark`); files already identical are not rewritten | | `shark skills install --project DIR` / `--dir DIR` | the skill into `DIR/.claude/skills/shark` (a project skill you can commit, or copy into a Space with `shark cp -r`) / into `DIR` | | `shark skills install --agents-md FILE [--space NAME] [--no-skill]` | also (or only) writes the AGENTS.md snippet into `FILE` between `` and ``, replacing an earlier block and keeping the rest of the file; `--space` puts your Space's name in it | | `shark skills install … --dry-run` | prints what it would create or update, writes nothing | | `shark skills snippet [--space NAME]` | prints the snippet | ## Secrets The value is read from **stdin** (pipe it), a **hidden prompt** or `--from-file`, never from argv (shell history and `ps` would keep it). Inject secrets (the default) never enter the VM: commands see a placeholder `shk_ph_…` and the node's egress proxy puts the real value only into requests to the scope's hosts ([concepts.md](concepts.md#secrets-inject-default-vs-exec)). ### Guided setup (a person at a terminal) `shark secrets` (or `shark secrets setup [--account | ] [NAME] [--preset P …]`) asks, one step at a time: where (the account, or one of your Spaces), the kind (a preset, custom, or the account's secret for a Space), the name, for custom the exposure (inject / exec, exec confirmed twice), hosts and where in the request, for the account which Spaces get it, for a Space whether to add the hosts to its egress, then the value (hidden, paste-friendly: a pasted newline does not submit; it says how long it is and whether it starts as the provider's keys do, e.g. `sk-ant-`, never what it is), and a review with the equivalent flag line. Nothing is sent before **Save**. Keys: **Enter** = the default (forward), **b** or **←** = back (the answers are kept; going forward again walks through them), **q**, Ctrl-C or Ctrl-D = cancel (nothing saved, exit 130), menus: **↑/↓** or a number. From the review you can jump to any step and come straight back. Flags given on the command line fill their steps. After saving it says what happens now (`$NAME=shk_ph_…`, the egress) and the next commands (`shark secrets status`, `shark code`). `shark secrets set` with a missing name or hosts on a terminal opens the same setup with what you gave; off a terminal it fails with the flag form. ### Commands | command | what | API | |---|---|---| | `shark secrets set NAME --preset P [--allow-egress]` | an inject secret for a known provider (below); `--allow-egress` also adds its hosts to the Space's egress allowlist | `PUT /v1/spaces/{id}/secrets` | | `shark secrets set NAME --host H [--host H2] [--in LOC]...` | an inject secret for any host; `LOC` is `bearer` (default), `basic`, `header:NAME` or `query:PARAM` | same | | `shark secrets set NAME --exec` | the value in the environment of commands that ask for it (`exec` with `"secrets": ["NAME"]`) and of terminals; any code they run can read it | same | | `… --from-file F` | the value from a file instead of stdin | | | `shark secrets set --account NAME --preset P [--apply all\|ref]` | an **account secret**: set once, used by every Space of the account (`all`, default; new Spaces and forks too) or only by Spaces that reference it (`ref`). Same flags as a Space's (`--host`, `--in`, `--exec`, `--from-file`) | `PUT /v1/secrets` | | `shark secrets set NAME --from-account` | the Space uses the account secret NAME (needed for `--apply ref`); no value | `PUT /v1/spaces/{id}/secrets` (`{"ref": "account"}`) | | `shark secrets status [ \| --account] [--agent claude\|codex\|opencode]... [--preset P]...` | **what is missing**: every secret of the scope (names only) with its state (ok, shadowed, unset reference, egress blocks a host, available to reference), last use, and each gap with the exact command that fixes it. Without `--agent` a Space is checked for the agent running in it. The node computes it (`--json` is its document); against an older node without that route the CLI computes the same document itself | `GET /v1/spaces/{id}/secrets/status`, `GET /v1/secrets/status` | | `shark secrets ls ( \| --account)` | a table: scope (`space`/`account`), name, preset, hosts, mode, apply, shadowed, last used (`--json`: the node's rows plus `preset`, `uses`, `last_used`) | `GET …/secrets`, `…/usage` | | `shark secrets rotate ( \| --account) NAME [--from-file F]` | a new value, the same exposure, hosts, locations and apply | `PUT …/secrets` | | `shark secrets edit ( \| --account) NAME [--preset P \| --host H... \| --add-host H \| --rm-host H] [--in LOC...] [--exec \| --inject] [--apply all\|ref] [--allow-egress]` | change how a stored secret is used **without typing the value again** (on a terminal with no flags: the review screen of the setup) | `PUT …/secrets` with `"keep_value": true` | | `shark secrets rm ( \| --account) NAME [--yes]` | delete (from the next command); asks on a terminal, needs `--yes` otherwise | `DELETE …/secrets/{name}` | | `shark secrets usage (--account \| ) NAME` | per Space and host: requests that got the value, requests refused for carrying it out of scope, last use (never the value) | `GET /v1/secrets/{name}/usage`, `GET /v1/spaces/{id}/secrets/{name}/usage` | | `shark secrets presets` | the presets | `GET /v1/secret-presets` (same catalog) | A Space secret with the same name as an account secret always wins in that Space (`ls` and `status` show the account one as shadowed). | preset | suggested name | hosts | where the value goes | |---|---|---|---| | `anthropic` | `ANTHROPIC_API_KEY` | `api.anthropic.com` | header `x-api-key` | | `openai` | `OPENAI_API_KEY` | `api.openai.com` | `Authorization: Bearer` | | `github` | `GITHUB_TOKEN` | `github.com`, `api.github.com` | Basic (git over https, user `x-access-token`), Bearer or `token` header (REST API) | | `npm` | `NPM_TOKEN` | `registry.npmjs.org` | `Authorization: Bearer` (`_authToken`) | ```sh shark secrets # guided setup (a terminal) shark secrets set demo ANTHROPIC_API_KEY --preset anthropic --allow-egress # prompts, input hidden gh auth token | shark secrets set demo GITHUB_TOKEN --preset github shark secrets set demo STRIPE_KEY --host api.stripe.com < stripe-key.txt shark secrets status demo --agent claude # what is missing, and the fix shark secrets edit demo STRIPE_KEY --add-host files.stripe.com # no value needed pbpaste | shark secrets rotate demo STRIPE_KEY shark secrets rm demo STRIPE_KEY --yes pbpaste | shark secrets set --account OPENAI_API_KEY --preset openai # every Space, forks included shark secrets usage --account OPENAI_API_KEY --json ``` ### Every setup step has a flag The review prints the flag line of what you chose (never the value); `shark secrets set` with those flags and the value on stdin stores exactly the same thing (tested: `cmd/shark/secrets_wizard_test.go`, `TestSetFlagParity`). | setup step | answer | flag | |---|---|---| | where | the account / a Space | `--account` / `` | | kind | a preset / custom / the account's secret | `--preset P` / `--host …` or `--exec` / `--from-account` | | name | `NAME` | `NAME` | | exposure | inject / exec | (default) / `--exec` | | hosts | `api.x.com, *.x.com` | `--host api.x.com --host '*.x.com'` | | where in the request | Bearer / a header / Basic / a query parameter | `--in bearer` / `--in header:X-Api-Key` / `--in basic` / `--in query:key` | | which Spaces (account) | every Space / only those that reference it | (default) / `--apply ref` | | egress (Space) | add the hosts | `--allow-egress` | | value | hidden prompt | stdin, or `--from-file F` | | value, kept (a stored secret) | Enter | `shark secrets edit …` | | review | Save / Cancel | (the command runs) / (not running it) | | rm's question | yes | `--yes` | ### `status --json` ```json {"scope": "space", "space": "demo", "agents": ["claude"], "presets": [], "egress": {"mode": "allowlist", "source": "space"}, "secrets": [{"name": "STRIPE_KEY", "scope": "space", "expose": "inject", "hosts": ["api.stripe.com"], "in": [{"kind": "bearer"}], "preset": "", "active": true, "shadowed": false, "missing": false, "egress_blocked": ["api.stripe.com"], "usage": {"available": true, "count": 7, "blocked": 0, "last_used": "2026-11-06T10:00:00Z"}}], "gaps": [{"code": "missing_secret", "severity": "error", "secret": "ANTHROPIC_API_KEY", "agent": "claude", "message": "…", "fix": "shark secrets set demo ANTHROPIC_API_KEY --preset anthropic --allow-egress < value.txt"}, {"code": "egress_not_allowed", "severity": "error", "secret": "STRIPE_KEY", "hosts": ["api.stripe.com"], "message": "…", "fix": "shark egress add demo api.stripe.com"}], "ok": false} ``` `ok` is false while any gap has `severity: "error"`; the exit code is 0 either way (1 only when the API fails). Gap codes: `missing_secret` (a harness or `--preset` expects it; the fix is `--from-account` when the account has it as `apply: ref`), `egress_not_allowed`, `account_secret_unset` (a reference to no account secret), and the `info` ones `shadowed`, `placeholder_blocked` (requests carried the placeholder to a host outside the scope: `usage` shows where), `egress_unknown` (an older node does not say which hosts its egress allows). `fix` is a command to run as printed (a `< value.txt` means the value on stdin; `HOST` is yours to fill in). **Errors** (stable codes, `--json` as everywhere): `not_a_terminal` (2: the guided setup without a terminal; the hint is the flag form), `confirmation_required` (2: `rm` without `--yes` off a terminal), `cancelled` (130: q, Ctrl-C or Ctrl-D in a question; nothing saved), `no_value` / `bad_value` (2: empty, under 8 or over 16 KiB, a control character, or a `shk_ph_` placeholder pasted back), `unknown_preset`, `unknown_agent` (2), `secret_is_account` (2: `rotate`/`edit` of an account secret through a Space: the hint is the `--account` form), `not_found` (1), `unsupported` (1: a node too old for `edit`'s `keep_value`; set it again with the value), `usage` (2). A host the Space's egress does not allow is warned, with the command that fixes it. The old argv form `NAME=VALUE` still works (stored as `--exec`) but prints a deprecation warning; `NAME=-` and `NAME=@file` too. ## Egress | command | what | API | |---|---|---| | `shark egress profiles [NAME] [--json]` | Shark's named host lists; with a name, its hosts with why each is there and how it was verified (`binary:@`, `probe`, `docs:…`, `legacy`), and what is left out on purpose | `GET /v1/egress/profiles` | | `shark egress use +PROFILE -PROFILE...` | add / remove profiles in one atomic change (`-npm` also takes npm out of `essentials`; `+open`/`-open` = mode open) | `PATCH …/egress {use}` | | `shark egress show [--effective]` (`get` is the same) | mode, profiles (and those Shark added by itself), your hosts, your deny list, where the policy comes from (`space`, `default`, `host`); `--effective` lists every host with the profile it comes from; `--json` has it all in `effective` | `GET /v1/spaces/{id}/egress` | | `shark egress add HOST...` / `rm HOST...` | allow or stop allowing hosts of your own, keeping the rest (`example.com`, `*.example.com`, `db.example.com:5432`, `203.0.113.0/24:5432`): one atomic change on the node. `rm` of a host not listed exits 1 `not_found` | `PATCH …/egress {add\|remove}` | | `shark egress deny HOST...` / `undeny HOST...` | refuse hosts (`name` or `*.suffix`) even when a profile has them (deny wins) / drop deny entries | `PATCH …/egress {add_deny\|remove_deny}` | | `shark egress denials [--since 1h\|30m\|2d\|RFC3339]` | hosts the egress proxy refused (names, ports, counts, first/last; never content), each with the profile that contains it and the command to allow it | `GET …/egress/denials` | | `shark egress clear ` | remove every host you added (the profiles stay) | `PATCH …/egress {clear}` | | `shark egress set [--mode allowlist\|deny\|open] [--base coding\|none] [--allow H,H] [--add H] [--remove H] [--auto on\|off]` | change the policy (`--allow` replaces the list); `open` only where the account may (`403 plan_restriction`) | same | Changes apply from the next command (the agent's session VM: from its next start); forks inherit them. `shark code --agent X` adds X's profile by itself (and with an API key the key's provider); `shark secrets set … --preset P --allow-egress` adds P's profile. A refused entry is `400 invalid_request` with `details.field` (`add[i]`/`allow[i]`/`use[i]`/`add_deny[i]`), `details.host` or `details.profile` and `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`, `deny_needs_allowlist`); `--json` prints them. ```bash shark egress profiles # the catalog shark egress use demo +opencode +openrouter -docker-hub shark egress show demo --effective # every host and the profile it comes from shark egress denials demo --since 1h --json # what was refused, and which profile has it ``` ## GitHub **Connect GitHub once; every Space of your account can then use `git` and `gh` on the repositories you chose** (new Spaces, existing ones and forks, with no per-Space step). The node keeps a managed `GITHUB_TOKEN` account secret (inject, apply all) fresh: short-lived App installation tokens, minted before a command when the current one is missing or expires within 15 min. Your commands see a placeholder; `git clone/fetch/push` over https and `gh` work as usual (`git@github.com:` URLs are rewritten to https), and commits default to your GitHub noreply address unless the Space sets its own `user.name`/`user.email`. | command | what | API (account service) | |---|---|---| | `shark github connect [--no-browser] [--wait] [--install]` | opens GitHub's authorization for the Shark GitHub App: installations of the App you can already access (your account, your organizations) are found and linked; with none, GitHub continues to the install page, where you pick your account and/or organizations and the repositories. `--wait` returns once the installation arrived. `--install` opens the install page itself (to add an organization). `--json`: `url` (the link to open), `install_url`, `connect_url`. Needs `secrets:write` (it puts an account credential in your Spaces, like an account secret): a `shark login` has it; an API key needs it in its scopes (`403 insufficient_scope`, `details.required`) | `POST /v1/github/install` | | `shark github status [--no-check]` | the installations (default owner, token health, repository count), the commit identity, the node's managed `GITHUB_TOKEN` (expiry, owners) and the Spaces that used it | `GET /v1/github/status?check=1`, node `GET /v1/secrets`, `…/GITHUB_TOKEN/usage` | | `shark github disconnect [--installation ID] [--keep-on-github] --yes` | forget the connection (all, or one installation). By default the App is uninstalled on GitHub, which revokes its tokens; every Space loses `GITHUB_TOKEN` within seconds | `DELETE /v1/github[/installations/{id}]` | | `shark github token --repo owner/name` | a token for that one repository (contents and pull requests, ≤ 1 h) stored as the Space's own `GITHUB_TOKEN` (it wins over the account's there); never printed | `POST /v1/github/token`, then `PUT …/secrets` | | `shark github link ` / `unlink ` | which repository a per-Space token is for (its node keeps it fresh while linked) / stop that | `GET` / `DELETE /v1/spaces/{id}/github` | With several installations (your account and organizations), requests that name a repository (`github.com//…`, `api.github.com/repos//…`) get that owner's token; others (GraphQL, `/user`) get the default one (your own account's). `shark code --repo` needs no token step when the account is connected. Without the App (`503 github_not_configured`), `gh auth token | shark secrets set --account GITHUB_TOKEN --preset github` works. The account's connection reaches **devbox** Spaces. An **sdk** Space (made by the API/SDK) gets it only with `github: true` (`shark space set --github on`); its commit identity is `--git-name`/`--git-email` (`git: {name, email}`). A platform whose users install **its own** GitHub App registers it once and links each Space to one installation: | command | what | API (account service) | |---|---|---| | `shark github app register --app-id ID --private-key-file app.pem [--json]` | the App once; the key is sealed, never shown again | `PUT /v1/github/app` | | `shark github app status [--json]` | registered?, app id, slug, linked Spaces | `GET /v1/github/app` | | `shark github app link --installation ID [--repo NAME]... [--json]` | a token of that installation (narrowed to `--repo`) stored as the Space's `GITHUB_TOKEN`; its node refreshes it while the Space is used | `POST /v1/github/app/link`, then `PUT …/secrets` | | `shark github app rm --yes [--json]` | forget the App and its links | `DELETE /v1/github/app` | SDK: `shark.github_app.register(app_id, pem)`, `.link(space, installation_id, repos=[…])`, `.status()`, `.remove()` (TS: `shark.githubApp.*`). Or, with a token you mint yourself, per Space: `shark secrets set GITHUB_TOKEN --preset github < token.txt` (SDK: `space.secrets.set("GITHUB_TOKEN", tok, preset="github")`). ## Coding agent and terminals in a session VM Details: [shark-code.md](shark-code.md). | command | what | |---|---| | `shark code [--repo owner/name\|URL] [--branch B] [--work-branch W] [--agent claude\|codex\|opencode] [--auth subscription\|api-key] [--remember space\|account] [--view] [--continue \| --resume [ID]] [space]` | create or wake the Space, clone the repository once (work branch `shark/`), start or join the agent, attach. `--continue` / `--resume [ID]` start the agent on a past conversation kept in the Space. Ctrl-\ detaches. Which agent: `--agent` (or `$SHARK_AGENT`) > the Space's remembered one > the account's > on a terminal, the first-run questions > exit 2 `harness_required` (no terminal, or `--json`, never asks; [shark-code.md](shark-code.md#choosing-the-agent---agent---remember---forget)) | | `shark code --agent list [--json]` | the coding agents this node runs, with the logins each supports (`GET /v1/agents`; the CLI's own list, `source: "cli"`, from an older node) | | `shark code --forget [space\|account\|all] [space]` | forget the remembered agent of the Space (default), the account's, or both; starts nothing. `--json`: `{"space", "forgot": [...], "still"}` | | `shark code --sessions [--agent A] [--json] [space]` | the agent's past conversations in the Space: id, start, last activity, size (never their content) | | `shark attach [--view] [--shell] [--resume ID] [--no-escape] [-- cmd...]` | join the running agent; with no agent running (or `--shell`) a **terminal**: a login shell in the Space's session VM. Plain `attach` never starts an agent; an agent you type there (`claude`, `codex`, `opencode`) runs as under `shark code` ([shark-code.md](shark-code.md#the-agent-and-the-terminal)). `~.` after Enter detaches | | `shark logs ` / `shark stop ` / `shark run-agent ""` | what the agent printed / end it / a headless run | ## Usage | command | what | API | |---|---|---| | `shark usage [--by space\|day] [--json]` | `key commands N command_s S` per row | `GET /v1/usage` | ## Other binaries (self-hosted and harness integration) | binary | for | page | |---|---|---| | `shark-bash` | runs unmodified Claude Code's Bash tool in Shark leases (a shell shim) | [agents.md](agents.md#c-unmodified-claude-code-the-shell-shim) | | `shark-rg` | answers Claude Code's Grep and Glob from an index instead of walking a FUSE mount | [agents.md](agents.md#grep-and-glob-shark-rg) | | `sharkfn` | Read, Write, Edit, Glob, Grep served from a Space's committed tree; an MCP server (`sharkfn serve -stdio`) | [agents.md](agents.md#d-mcp-sharkfn) | | `sharkfs` | the node itself (`sharkfs api`), the SharkFS mount, durability and the usage meter | operators only | | `shark service …` | declares Postgres/Redis on the host side of a Space (`catalog`, `add postgres@17`, `rm`, `ls`, `checkpoint`, `env`); as a client, commit `.shark/services.toml` instead ([concepts.md](concepts.md#services-that-sleep)) | | `shark service catalog` (works anywhere): ```text postgres@16 port 5432 2 vCPU 1024 MiB env DATABASE_URL PGDATABASE PGHOST PGPASSWORD PGPORT PGUSER POSTGRES_URL postgres@17 port 5432 2 vCPU 1024 MiB env DATABASE_URL PGDATABASE PGHOST PGPASSWORD PGPORT PGUSER POSTGRES_URL redis@7 port 6379 1 vCPU 512 MiB env REDIS_HOST REDIS_PORT REDIS_URL ``` Without `SHARK_API` and a key, `shark create `, `shark fork`, `shark log`, `shark head` and `shark checkout` work on a local Space store (`SHARK_HOME`): the early prototype of the content-addressed Space, no server and no VM. --- # Python SDK reference Every public class and method of the Python SDK (`shark`, version 0.1.0): signature, parameters, return type, the errors it raises, and a short example. Python 3.9+, standard library only. Checked against the code by `proofs/a12-docs-acceptance/sdkcheck.py` (it fails when this page and `sdk/python` drift apart). The TypeScript SDK: [sdk-typescript.md](sdk-typescript.md). The HTTP routes behind each call: [api.md](api.md#route--cli--sdk). Status follows [api.md](api.md): **GA-path** (served and tested), **beta** (served; local-only tests or a gap noted), **coming** (answers `422 unsupported` today). A method has the status of the route it calls; it is marked below where it is not GA-path. ## Install ```sh ``` The package is not on PyPI yet; the placeholder is explained in [quickstart.md](quickstart.md#0-what-you-need). It imports as `shark`. ## Authentication Put your **account key** (`shk_live_…`) in the environment and construct the client with no arguments: ```sh export SHARK_API_KEY=... # never in code, in a file of the Space, or in an agent's prompt ``` ```python from shark import Shark shark = Shark() ``` The client sends the key only to the account service (`https://api.sharkmagic.si`), which returns a 5-minute node token; the SDK refreshes it 60 s before it expires, finds the node that holds each Space, and sends the call there. The key never reaches a node. Without `SHARK_API_KEY`, the SDK reads `~/.shark/credentials` (written by `shark login`; ignored unless its mode is `0600`). **Leave `SHARK_API` unset.** `SHARK_API` (or `base_url=`) is *direct mode*, for a self-hosted node and that node's own keys; an account key there answers `401 unauthenticated`. Mode order: `control_plane=` > `base_url=` > `$SHARK_CP` > `$SHARK_API` > the credentials file's `SHARK_CP` / `SHARK_API` > `https://api.sharkmagic.si`. ## Errors Every API error raises a subclass of `shark.SharkError` with these attributes: `code` (the stable code from [errors.md](errors.md); branch on it), `status` (HTTP), `message`, `request_id`, `details`, `retryable`, `retry_after` (seconds, from `Retry-After`). A code the SDK does not know maps by HTTP status (400 → `InvalidRequestError`, 401 → `AuthenticationError`, 402 → `CreditsExhaustedError`, 403 → `PermissionDeniedError`, 404 → `NotFoundError`, 409/412 → `ConflictError`, 410 → `OpExpiredError`, 413 → `InvalidRequestError`, 422 → `UnsupportedError`, 429 → `RateLimitError`, 5xx → `ServerError`). **Retries are built in.** Connection errors, `429` and `5xx` marked retryable are retried (up to `max_retries`, honouring `Retry-After`; `lease_cap_reached` keeps waiting up to `queue_timeout`), always with the same `op_id` or `Idempotency-Key`, so a command never runs twice and a create never makes two Spaces. Only the final error reaches you. **A non-zero exit is not an exception**: `run()` returns it in `ExecResult.exit` (unless `check=True`). | class | extends | API codes | raised when | |---|---|---|---| | `SharkError` | `Exception` | | base of all; also `code="config"` (no API key, bad URL, `balance()` in direct mode), `code="needs_control_plane"` (`github_app` in direct mode) | | `AuthenticationError` | `SharkError` | `unauthenticated`, `auth_rate_limited` | bad, revoked or expired key; account key in direct mode | | `PermissionDeniedError` | `SharkError` | `insufficient_scope`, `plan_restriction`, `tenant_not_allowed` | the key lacks a scope (`details.required`) or the account the feature | | `NotFoundError` | `SharkError` | `not_found` | unknown id or name, or another account's Space | | `ConflictError` | `SharkError` | `conflict`, `precondition_failed` | state does not allow it; `If-Match` mismatch | | `IdempotencyMismatchError` | `ConflictError` | `idempotency_mismatch`, `idempotency_in_progress` | an `op_id` / key reused for a different request | | `OpExpiredError` | `SharkError` | `op_expired` | the op's stored output is past its 7 days | | `InvalidRequestError` | `SharkError` | `invalid_request`, `invalid_op_id`, `path_outside_space`, `payload_too_large`, `upgrade_required` | fix the request (`details.field`, `details.max_s`) | | `UnsupportedError` | `SharkError` | `unsupported` | a route this node does not serve yet (`details.phase`) | | `RateLimitError` | `SharkError` | `rate_limited` | request rate per key, after the retries | | `LeaseCapError` | `RateLimitError` | `lease_cap_reached` | concurrent commands at the cap after `queue_timeout`; the command did not start; `.scope` = `details.scope` | | `SpaceLimitError` | `RateLimitError` | `space_limit_reached` | the node's live-Space cap | | `QuotaExceededError` | `SharkError` | `quota_exceeded`, `insufficient_storage` | an account cap (`details.quota`: `spaces`, `awake`) or disk | | `CreditsExhaustedError` | `SharkError` | `credits_exhausted` | balance at zero; reads keep working | | `ServerError` | `SharkError` | `internal` | after the retries; quote `request_id` | | `CapacityError` | `ServerError` | `capacity_unavailable`, `out_of_capacity`, `canceled` | the node was too busy; **the command did not run** | | `DurableUnavailableError` | `ServerError` | `durable_unavailable` | `checkpoint()` could not reach the durable store | | `ConnectionError` | `SharkError` | | the network failed after the retries (`code="connection"`); `shark.ConnectionError`, not the built-in | | `CommandFailedError` | `SharkError` | | `run(..., check=True)` and the exit is not 0 (`code="command_failed"`; `.exit`, `.stdout`, `.stderr`, `.op_id`, `.result`) | | `InfraError` | `SharkError` | | `run()` got `shark_status: infra_error` twice for the same `op_id` (`code="infra_error"`) | | `CommandTimeoutError` | `SharkError` | | `run()` got `shark_status` `timeout` or `infra_timeout` (`code` is that status) | | `WaitTimeoutError` | `SharkError` | | `op.wait(timeout=...)` ran out of time (`code="wait_timeout"`, `.op_id`, `.timeout`); the command keeps running | ```python from shark import Shark, CreditsExhaustedError, QuotaExceededError, SharkError try: space = shark.spaces.create(name="job-1") except QuotaExceededError as e: print(e.code, e.details.get("quota")) # spaces | awake: delete or hibernate a Space except CreditsExhaustedError: print("top up; files stay readable") except SharkError as e: print(e.code, e.request_id) ``` ## Client ### `Shark` ```python Shark(api_key=None, base_url=None, timeout=60, max_retries=5, queue_timeout=600, ca_file=None, control_plane=None) ``` The synchronous client. `api_key`: default `$SHARK_API_KEY`. `base_url`: a self-hosted node (direct mode). `control_plane`: the account service URL (default `https://api.sharkmagic.si`, or `$SHARK_CP`). `timeout`: seconds per HTTP request (commands get their own). `max_retries`: retries of a retryable failure. `queue_timeout`: seconds to keep retrying `lease_cap_reached`. `ca_file`: a CA bundle for a self-hosted node with its own certificate (also `$SHARK_API_CA`). Raises `SharkError(code="config")` without a key or with a bad URL (plain `http` only to loopback). Attributes: `spaces`, `secrets` (account secrets), `space_defaults`, `github_app`, `keys`, `usage`. Usable as a context manager (`with Shark() as shark:`). ### `shark.me` ```python shark.me() -> dict ``` Your account (`tenant`), plan, and the credential's id, kind, scopes and expiry (`GET /me`). ### `shark.capabilities` ```python shark.capabilities() -> dict ``` The node's API version, features, limits and lease shapes (`GET /capabilities`). Read features from here rather than assuming them. ### `shark.limits` ```python shark.limits() -> List[dict] ``` Every limit that applies to you with current use: `[{"name", "scope", "limit", "used"}, …]` (`GET /quotas`; also the `node_admission` row). Read limits from here, never from memory ([limits.md](limits.md)). ```python caps = {q["name"]: (q["used"], q["limit"]) for q in shark.limits()} ``` ### `shark.close` ```python shark.close() -> None ``` Closes the pooled connections. Called by `with Shark() as shark:` on exit. ### `shark.transport` ```python shark.transport -> Transport ``` The low-level transport (`transport.request(operation_id, params, query=…, json_body=…)` sends one call of `shark.ROUTES` with the retry policy above; `transport.stats` counts requests, retries and reconnects). Not covered by this reference; prefer the methods below. ### `new_op_id` ```python new_op_id() -> str ``` A fresh time-ordered id (UUIDv7 layout). The SDK generates one per call when you pass none; in an agent loop pass your tool-call id as `op_id` instead, so a retried tool call replays rather than runs twice. ## Spaces A Space is a persistent workspace; each command runs in a fresh microVM with it mounted ([concepts.md](concepts.md#space)). **SDK Spaces.** Spaces the SDK creates are **sdk** Spaces: nothing of your account reaches them unless the Space asks. Every option below can be set at create, changed with `space.update(...)`, and overridden per fork; leave it out for the default, pass `None` to `update` to go back to the profile's default ([api.md](api.md#sdk-spaces-and-devbox-spaces-profile)). | option | sdk default | values | |---|---|---| | `profile` | `"sdk"` | `"sdk"` or `"devbox"` (as `shark create` / `shark code`: the account's secrets and GitHub, background jobs, the Space CLI); create only | | `account_secrets` | `"none"` | `"all"` (the account's `apply: all` secrets), `"none"` (only those the Space references), or a list of names | | `github` | `False` | `True`: the account's GitHub connection (a managed `GITHUB_TOKEN`; connect it once with `shark github connect`, [cli.md](cli.md#github)) | | `git` | none | `{"name": …, "email": …}`: the Space's commit identity | | `space_cli` | `False` | `True`, or a list of scopes (subset of `spaces:read`, `files:read`, `exec`, `usage:read`): a Space credential for the `shark` CLI inside the Space | | `keep_background` | `False` | `True`: a process a command leaves running (`cmd &`) stays as a job (beta, [background jobs](#background-jobs-processes)) | | `keep_awake` | `False` | `True`: kept jobs never sleep when idle (**billed awake** the whole time) | | `egress` | node default | a policy dict, or `"deny"`, `"coding"` (package registries and code hosts), `"open"` ([egress](#egress)) | | `labels` | none | `{"k": "v"}`; `shark.spaces.list(label="k:v")` filters | Changing `account_secrets`, `github` or `git` needs the `secrets:write` scope; `keep_background`, `keep_awake` and `space_cli` need `exec`; `egress` needs `network:write`. A refused value is `InvalidRequestError` with `details.field`, and nothing of the request is applied. ### `shark.spaces.create` ```python shark.spaces.create(*, name=None, labels=None, egress=None, env=None, from_space=None, at=None, idempotency_key=None, tenant=None, profile=UNSET, account_secrets=UNSET, github=UNSET, git=UNSET, keep_background=UNSET, keep_awake=UNSET, space_cli=UNSET) -> Space ``` Creates a Space (`POST /spaces`). `name`: unique in your account, usable in place of the id. `env`: non-secret variables for every command. `from_space` / `at`: start from another Space's content (at a commit), O(1). `idempotency_key`: generated if absent; the SDK retries with the same key, so a retried create never makes two Spaces. `tenant`: leave unset (the account comes from the key; another value is `PermissionDeniedError` `tenant_not_allowed`). `UNSET` means "not sent": the profile's default applies. Raises `QuotaExceededError` (`details.quota` `spaces` or `awake`), `CreditsExhaustedError`, `CapacityError`, `ConflictError` (duplicate name), `InvalidRequestError`. ```python space = shark.spaces.create(name="acme-1", labels={"customer": "acme"}, egress="coding", account_secrets=["OPENAI_API_KEY"], git={"name": "Acme Bot", "email": "bot@acme.test"}) ``` ### `shark.spaces.get` ```python shark.spaces.get(id_or_name) -> Space ``` One Space by id (`tti-…`) or name; does not wake it. Raises `NotFoundError`. ### `shark.spaces.list` ```python shark.spaces.list(*, label=None, state=None, name=None, limit=100) -> Iterator[Space] ``` Every matching Space, newest first, pages followed for you; does not wake hibernated ones. `label`: `"k:v"` or a list (all must match). `state`: `"ready"` or `"hibernated"`. `limit`: page size. ```python for s in shark.spaces.list(label="customer:acme"): print(s.id, s.state) ``` ### `space.id` ```python space.id -> str ``` The Space id (`tti-<32 hex>`). ### `space.state` ```python space.state -> str ``` `ready` or `hibernated`, as of the last response (`space.refresh()` to update). ### `space.labels` ```python space.labels -> Dict[str, str] ``` ### `space.profile` ```python space.profile -> str ``` `sdk` or `devbox` (Spaces made before profiles report `devbox`). ### `space.refresh` ```python space.refresh() -> Space ``` Re-reads the Space (`GET /spaces/{id}`), including `settings`. `space.data` holds the raw JSON. ### `space.settings` ```python space.settings() -> dict ``` Every per-Space setting with its source: `name`, `labels`, `env`, `egress` (with `effective.hosts`), `dormancy`, `secrets` (names only), `ports`, `durable`, `profile`, `account_secrets`, `github`, `git`, `keep_background`, `keep_awake`, `space_cli` (each `{value, source}`), `limits` ([api.md](api.md#spaces)). ```python print(space.settings()["account_secrets"]) # {'value': ['OPENAI_API_KEY'], 'source': 'space'} ``` ### `space.update` ```python space.update(*, name=None, labels=None, env=None, egress=None, account_secrets=UNSET, github=UNSET, git=UNSET, keep_background=UNSET, keep_awake=UNSET, space_cli=UNSET) -> Space ``` Changes settings in one `PATCH /spaces/{id}`: every field is validated before any is applied. `labels` and `env` replace the whole map. `None` for an account option sets it back to the profile's default; `None` for `name`, `labels`, `env`, `egress` means "unchanged". Raises `InvalidRequestError` (`details.field`), `PermissionDeniedError` (`insufficient_scope`, or `plan_restriction` for egress `open`). ```python space.update(github=True, keep_background=None) ``` ### `space.fork` ```python space.fork(count=1, labels=None, name_prefix=None, at=None, idempotency_key=None, inherit_labels=True, account_secrets=UNSET, github=UNSET, git=UNSET, keep_background=UNSET, keep_awake=UNSET, space_cli=UNSET) -> List[Space] ``` `count` (1–50) copies of the Space at HEAD (or at commit `at`): files O(1); `env` and egress copied; the Space's own secrets, processes and service memory not (account secrets reach a fork as any Space of the account). Forks keep the source's profile and account options unless you pass them. `inherit_labels=False`: the forks get only `labels`. Retried with one `Idempotency-Key`. Raises `QuotaExceededError`, `InvalidRequestError` (count), `NotFoundError`. ```python attempts = space.fork(count=3, labels={"try": "1"}) results = [f.run("pytest -q").exit for f in attempts] ``` ### `space.hibernate` ```python space.hibernate() -> Space ``` Hibernates now (any later request wakes it). Raises `ConflictError` while something runs. ### `space.wake` ```python space.wake() -> dict ``` Wakes now; the result has `wake_ms`. Not needed before a command: any request wakes the Space. ### `space.checkpoint` ```python space.checkpoint() -> dict ``` Returns once HEAD and its objects are in the durable store. Raises `DurableUnavailableError`. ### `space.delete` ```python space.delete(purge=False) -> None ``` Waits for the running command, then deletes the Space; `purge=True` also deletes the durable copy. Deleting a Space that is already gone is not an error. ### `space.audit` ```python space.audit() -> dict ``` Commits from HEAD with their `op_id`, changed paths and exit (`GET /spaces/{id}/audit`). ### `space.usage` ```python space.usage(start=None, end=None) -> dict ``` This Space's metered usage (beta; `GET /spaces/{id}/usage?from&to`); see [usage](#usage-quotas-and-balance). ## Commands Three ways to run a command, all exactly once per `op_id` ([concepts.md](concepts.md#op-and-op_id-exactly-once)): `run` waits (sync; `timeout` default 120 s, max 900), `stream` yields live events, `start` returns an `Op` at once (stream and async: default 1,800 s, max 7,200). A `timeout` above the maximum is `InvalidRequestError` with `details.max_s`. Common parameters: | parameter | meaning | |---|---| | `cmd` | a bash command line (≤ 256 KiB), run in a fresh microVM with the Space mounted; file changes are committed whatever the exit code | | `timeout` | seconds before the command is stopped | | `cwd` | directory relative to the Space root | | `env` | non-secret variables for this command | | `stdin` | `str` or `bytes` (≤ 1 MiB) | | `op_id` | the command's id (1–64 of `[A-Za-z0-9_.:-]`); generated if absent. Use your tool-call id | | `background` | `True`: what the command leaves running (`cmd &`) stays as a job; `False`: it ends with the command; `None`: the Space's `keep_background` (sdk: off) | | `secrets` | names of the Space's `exec`-exposed secrets to put in this command's environment | ### `space.run` ```python space.run(cmd, *, timeout=120, cwd=None, env=None, stdin=None, op_id=None, check=False, on_stdout=None, on_stderr=None, retry_conflict=False, request_timeout=None, background=None, secrets=None) -> ExecResult ``` Runs one command and waits. `check=True` raises `CommandFailedError` on a non-zero exit. `on_stdout` / `on_stderr`: callbacks with each chunk of text as it arrives (the call then streams). `retry_conflict=True`: on `shark_status: "conflict"` (a concurrent write to the same paths) re-run once with a new `op_id`. `request_timeout`: the HTTP wait (default `timeout + 30`). An `infra_error` is retried once with the same `op_id`; a node too busy to start the command is retried after its `Retry-After`. Raises `InfraError`, `CommandTimeoutError`, `CapacityError` (still busy: the command did not run), `CommandFailedError` (with `check`), and the API errors (`LeaseCapError`, `CreditsExhaustedError`, `NotFoundError`, …). ```python r = space.run("npm ci && npm test", cwd="web", env={"CI": "1"}, timeout=900, op_id=tool_call.id) print(r.exit, r.shark_status, r.stdout[-2000:]) ``` ### `space.stream` ```python space.stream(cmd, *, timeout=1800, cwd=None, env=None, stdin=None, op_id=None, idle_timeout=45.0, background=None, secrets=None) -> Iterator[Event] ``` Live events: `start`, `stdout`, `stderr`, `detached`, then `exit` (the result) or `error`. A dropped connection resumes from the last event id: each event is yielded exactly once. `idle_timeout`: seconds of silence (the server pings every 15 s) before reconnecting. Raises `ConnectionError` when the stream cannot be resumed. ```python for ev in space.stream("make -j8"): if ev.type in ("stdout", "stderr"): print(ev.text, end="") elif ev.type == "exit": print("exit", ev.exit) ``` ### `space.start` ```python space.start(cmd, *, timeout=1800, cwd=None, env=None, stdin=None, op_id=None, background=None, secrets=None) -> Op ``` Starts the command and returns its `Op` at once (`"wait": false`, `202`). Detach and come back later, from this or any other process, with `space.op(op_id)`. ```python op = space.start("python train.py", timeout=7200) save(op.op_id) # later: space.op(op_id).wait() ``` ### `space.op` ```python space.op(op_id) -> Op ``` Re-attaches to an op by id. Raises `NotFoundError`, `OpExpiredError` (output past 7 days). ### `space.ops` ```python space.ops(state=None, limit=50) -> List[dict] ``` Recent ops, newest first (`state`: `"running"` or `"done"`). ### `op.state` ```python op.state -> str ``` `running` or `done`, as of the last response. `op.op_id`, `op.space` and `op.data` (the raw op) are attributes. ### `op.refresh` ```python op.refresh() -> Op ``` Re-reads the op: `state`, `cmd`, `result`. ### `op.events` ```python op.events(after=0, idle_timeout=45.0) -> Iterator[Event] ``` Every event after event id `after`, live or after the op ended (kept 7 days), each exactly once. ### `op.wait` ```python op.wait(poll=0.5, timeout=None) -> ExecResult ``` Waits for the result. `timeout=None` follows the event stream to the end (`poll` is not used). With `timeout` (seconds) it checks the op every `poll` seconds and raises `WaitTimeoutError` (`code="wait_timeout"`, `.op_id`, `.timeout`) if the op is still running at the deadline: **the command keeps running**; wait again, read `op.state` after `op.refresh()`, or stop it with `op.signal()`. A negative `timeout` or a `poll` of 0 or less raises `ValueError`. ```python from shark import WaitTimeoutError op = space.start("make test", timeout=1800) try: res = op.wait(timeout=60, poll=1) except WaitTimeoutError: op.signal("INT") ``` ### `op.output` ```python op.output() -> str ``` The op's full stored stdout (up to the op log's cap, 32 MiB per stream). ### `op.signal` ```python op.signal(sig="TERM") -> None ``` Sends `TERM`, `INT`, `KILL` or `HUP` to the running command. ### `ExecResult` | field | meaning | |---|---| | `op_id` | the command's id | | `exit` | the exit code (non-zero is a normal result) | | `shark_status` | `ok`, `conflict` (re-run with a new `op_id`), `timeout`, `infra_timeout`, `infra_error`, `capacity_unavailable`, `signaled` ([concepts.md](concepts.md#lease-one-command-one-microvm)) | | `stdout`, `stderr` | output (sync: the last 1 MiB of each stream) | | `stdout_truncated` | `True` when stdout was cut; `op.output()` has the rest | | `stderr_truncated` | the same for stderr | | `commit` | the commit the command made, or `None` when it changed no file | | `changed_paths` | number of paths changed | | `wall_ms` | time at the node, end to end | | `queue_ms` | time waiting for a slot | | `wake_ms` | set when the request woke the Space | | `run_s` | the command's own run time | | `replayed` | `True` when this is the stored result of an earlier request with the same `op_id` | | `processes` | jobs the command left running (with `background`) | | `raw` | the response JSON | ### `event.id` ```python event.id -> Optional[str] ``` The event's sequence number (resume point). ### `event.type` ```python event.type -> str ``` `start`, `stdout`, `stderr`, `detached`, `exit`, `error` (and, for `files.watch()`, `change`). ### `event.data` ```python event.data -> dict ``` The event's JSON. ### `event.text` ```python event.text -> str ``` A `stdout`/`stderr` chunk as text (base64 chunks decoded, invalid UTF-8 replaced). ### `event.bytes` ```python event.bytes -> bytes ``` The same chunk as bytes. ### `event.exit` ```python event.exit -> Optional[int] ``` The exit code on an `exit` event. ## Files Reads come from the committed tree **without starting a VM** (and without waking a hibernated Space). Writes are ops: one commit each, exactly once per `op_id`; a write that races a command on the same path is `ConflictError` (`details.paths`). Paths are relative to the Space root; one that escapes it is `InvalidRequestError` (`path_outside_space`). `at`: read at a past commit. ### `space.files.read` ```python space.files.read(path, at=None) -> bytes ``` Raises `NotFoundError`. ### `space.files.read_text` ```python space.files.read_text(path, at=None, encoding="utf-8") -> str ``` ```python print(space.files.read_text("out/report.json")) ``` ### `space.files.write` ```python space.files.write(path, data, *, mode=None, op_id=None, if_match=None) -> dict ``` Writes one file (`str` or `bytes`, ≤ 512 MiB) as one commit; parents are created. `mode`: e.g. `"0755"`. `if_match`: an etag from `stat()`: compare-and-swap, `ConflictError` (`precondition_failed`) if the file changed. Raises `InvalidRequestError` (`payload_too_large`), `QuotaExceededError` (`insufficient_storage`). ### `space.files.stat` ```python space.files.stat(path, at=None) -> dict ``` `{"type", "size", "mode", "mtime", "etag", "commit"}` without the content. Raises `NotFoundError`. ### `space.files.exists` ```python space.files.exists(path) -> bool ``` ### `space.files.list` ```python space.files.list(path="/", depth=1, glob=None, at=None) -> List[FileEntry] ``` The tree under `path` (pages followed). `glob`: e.g. `"**/*.py"`. ### `space.files.remove` ```python space.files.remove(path, recursive=False, op_id=None) -> dict ``` ### `space.files.move` ```python space.files.move(src, dst, overwrite=False, op_id=None) -> dict ``` ### `space.files.upload_dir` ```python space.files.upload_dir(local, remote="/", replace=False, op_id=None) -> dict ``` Uploads a local directory as one commit (tar + gzip, ≤ 2 GiB). `replace=True`: `remote` becomes exactly the upload. ```python space.files.upload_dir("./repo", "repo") ``` ### `space.files.download_dir` ```python space.files.download_dir(remote, local, at=None) -> int ``` Downloads a directory into `local` and returns the number of files. Unsafe archive members (absolute paths, `..`) are refused. ### `space.files.watch` ```python space.files.watch(path=None, after_commit=None) -> Iterator[Event] ``` One `change` event per landed commit (`commit`, `op_id`, `paths`), from `after_commit` or now. Runs until you stop iterating. ### `FileEntry` | field | meaning | |---|---| | `path` | relative to the Space root | | `type` | `file`, `dir` or `symlink` | | `size` | bytes | | `mode` | e.g. `0644` | | `mtime` | modification time | | `hash` | content hash | | `target` | a symlink's target | ## Background jobs (processes) **Beta.** On a node that keeps background jobs, a process a command leaves running (with `background=True`, or a Space with `keep_background=True`) becomes a job `bg-1`, `bg-2`, … in its own VM; later commands reach its ports at `127.0.0.1:` ([concepts.md](concepts.md#background-processes)). **A job is billed (process seconds) while it is awake**, and with `keep_awake=True` it never sleeps: stop jobs you no longer need. ### `space.processes.list` ```python space.processes.list() -> List[dict] ``` Jobs: `id` (`bg-1`), `op_id`, `state` (`running`, `paused`, `snapshotted`, `exited`), `ports`, `exit`. ### `space.processes.get` ```python space.processes.get(pid) -> dict ``` ### `space.processes.logs` ```python space.processes.logs(pid, tail_bytes=None) -> str ``` Its output (committed log: it grows at the job's snapshots and at its end). ### `space.processes.signal` ```python space.processes.signal(pid, sig="TERM") -> None ``` Stops it through its keeper, so its writes land. ```python space.run("(timeout 1800 python -m http.server 8000 > srv.log 2>&1 &)", background=True) print(space.run("curl -s localhost:8000 | head -1").stdout) for p in space.processes.list(): space.processes.signal(p["id"]) ``` ## Exposed processes and previews **Live on the hosted node** (beta). A server declared on a port starts on the first request, sleeps after `idle_s` (default 30 s) without traffic, wakes on the next request, follows HEAD (hot reload sees every commit) and is reachable at `https://-.preview.sharkmagic.si`. **Private by default**: only a browser that opened a one-time login link from the account sees it; `public=True` opens it to anyone with the URL. An exposed process is **billed (service seconds) while it is awake**, by size, and not while asleep: remove the ones you no longer need, and use `shark.ports.list(state="running")` to find what is awake. The account caps how many run at once (`awake_services` in `shark.limits()`); a start past it is refused. A node without the ports runner raises `UnsupportedError` ([api.md](api.md#exposed-processes-previews-and-the-terminal)). ### `space.ports.declare` ```python space.ports.declare(port, cmd, *, cwd=None, env=None, idle_s=None, public=None) -> dict ``` Declares (or replaces) the server on `port` (`PUT /spaces/{id}/ports/{port}`); returns the exposure. Replacing one restarts it. `env` is for non-secret values (secrets go through `space.secrets`). ```python p = space.ports.declare(3000, "npm run dev", cwd="app", idle_s=60) print(p["url"], p["state"]) # https://-3000.preview.sharkmagic.si declared link = space.ports.login_link(3000)["login_url"] # hand it to a person; it works once ``` ### `space.ports.list` ```python space.ports.list() -> List[dict] ``` Every exposure of the Space: `port`, `state` (`declared`, `starting`, `running`, `asleep`, `exited`, `failed`), `url`, `cmd`, `cwd`, `idle_s`, `public`, `created_at`, `connections`, `starts`, `wakes`, `sleeps`, `last_request_at` (null = no request since the node started), `awake_since` (null unless running), `exit_code`, `error`. ### `space.ports.get` ```python space.ports.get(port) -> dict ``` One exposure (`NotFoundError` when the port is not declared). ### `space.ports.set_public` ```python space.ports.set_public(port, public) -> dict ``` `True`: anyone with the URL reaches it (and every request can wake it); `False`: one-time links only. `PermissionDeniedError` (`plan_restriction`) where public previews are off. ### `space.ports.sleep` ```python space.ports.sleep(port) -> dict ``` Puts it to sleep now (no compute billed while asleep); the next request wakes it. ### `space.ports.logs` ```python space.ports.logs(port) -> str ``` The process's output (not part of the Space's tree). ### `space.ports.remove` ```python space.ports.remove(port) -> None ``` Stops it and revokes every preview cookie: open preview tabs stop working. ### `space.ports.login_link` ```python space.ports.login_link(port, path=None) -> dict ``` `{url, public, login_url, expires_at}`: `login_url` logs in whoever opens it, once, within 60 s, and sets a cookie for that preview host only. Generate it when a person is about to open it; never store, log or paste it. ### `shark.ports.list` ```python shark.ports.list(state=None) -> List[dict] ``` Every exposure of the account on its node (`GET /ports`), awake first; each entry as `space.ports.list()` plus `space`, `space_name` and `awake` (running or starting: billed now). `state`: one state or a list of them. ```python for p in shark.ports.list(state=["running", "starting"]): if p["last_request_at"] is None: shark.spaces.get(p["space"]).ports.sleep(p["port"]) ``` ## Secrets **Beta.** Values are write-only: no call returns one. Default exposure is `inject`: commands see a placeholder `shk_ph_…` and the node's egress proxy puts the real value only into requests to the secret's `hosts`; **the value never enters the VM**. `exec=True` puts the value in the environment of commands that list it in `secrets=[...]` (the response warns `secret_exposed_to_code`). `in_`: where the value goes: `"bearer"` (default), `"basic"`, `"header:NAME"`, `"query:PARAM"`. `preset`: `"anthropic"`, `"github"`, `"npm"`, `"openai"` fill `hosts` and `in_` ([concepts.md](concepts.md#secrets-inject-default-vs-exec)). Writing secrets needs `secrets:write`. `space.secrets` holds the Space's own secrets and references to account secrets; `shark.secrets` holds the account's (set once; `apply="all"` reaches every Space whose `account_secrets` takes it, `apply="ref"` only Spaces that reference it). A Space secret wins over an account secret of the same name. Inconsistent arguments (an `exec` secret with `hosts`, an `inject` secret without `hosts` or `preset`) raise `ValueError` before any request. ### `space.secrets.set` ```python space.secrets.set(name, value, *, preset=None, hosts=None, in_=None, exec=False, allow_egress=False) -> dict ``` Stores a Space secret; returns `{"names", "warnings"}` (e.g. `scope_host_not_allowed`). `allow_egress=True` also adds the hosts to the Space's egress allowlist. ```python space.secrets.set("STRIPE_KEY", key, hosts=["api.stripe.com"]) space.secrets.set("GITHUB_TOKEN", tok, preset="github", allow_egress=True) # bring your own GitHub token space.secrets.set("DB_URL", url, exec=True) space.run("python migrate.py", secrets=["DB_URL"]) ``` ### `space.secrets.reference` ```python space.secrets.reference(name) -> dict ``` Uses the account secret of this name in this Space (how an sdk Space with `account_secrets="none"` takes one). A name with no account secret is accepted with the warning `account_secret_not_found`. ### `shark.secrets.set` ```python shark.secrets.set(name, value, *, preset=None, hosts=None, in_=None, exec=False, apply=None) -> dict ``` Stores an account secret (`PUT /secrets`). `apply`: `"all"` (default) or `"ref"`. ```python shark.secrets.set("OPENAI_API_KEY", key, preset="openai", apply="ref") ``` The methods below exist on both `space.secrets` and `shark.secrets`. ### `secrets.presets` ```python secrets.presets() -> List[dict] ``` The provider catalog: `name`, `suggested_name`, `hosts`, `in`, `note`. ### `secrets.list` ```python secrets.list() -> List[dict] ``` Names, `expose`, `hosts`, `in`, `source` (`space` or `account`), `shadowed`, `missing`, `excluded`, `apply`: never values. ### `secrets.get` ```python secrets.get(name) -> dict ``` One entry of `list()` (the one in effect). Raises `NotFoundError`. ### `secrets.rotate` ```python secrets.rotate(name, value) -> dict ``` A new value; exposure, hosts and locations stay. On `space.secrets`, a name that is the account's raises `ValueError` (rotate it with `shark.secrets.rotate`). ### `secrets.edit` ```python secrets.edit(name, *, preset=None, hosts=None, in_=None, exec=None, apply=None) -> dict ``` Changes hosts, locations, exposure or `apply` and keeps the stored value. ### `secrets.remove` ```python secrets.remove(name) -> None ``` Removing a name that is not there is not an error. ### `secrets.usage` ```python secrets.usage(name) -> dict ``` Per host (and, for an account secret, per Space): `count` (requests that got the value), `blocked` (requests refused for carrying its placeholder elsewhere), `last_used`. ### `secrets.status` ```python secrets.status(agents=None, presets=None) -> dict ``` What is missing and the fix, computed by the node (`GET /spaces/{id}/secrets/status`, `GET /secrets/status`; the `shark secrets status --json` document): `scope`, `space`, `agents`, `presets`, `egress`, `secrets` (each with `active`, `shadowed`, `missing`, `egress_blocked`, `usage`), `gaps` (`code`, `severity`, `secret`, `agent`, `hosts`, `message`, `fix`) and `ok` (no gap of severity `error`). Gap codes: `missing_secret`, `egress_not_allowed`, `account_secret_unset` (errors), `shadowed`, `placeholder_blocked`, `egress_unknown` (info). `agents`: `claude`, `codex`, `opencode` (on a Space, the Space's own agent when none is given); `presets`: preset names. Never a value. ```python st = space.secrets.status(agents=["claude"]) for g in st["gaps"]: print(g["severity"], g["message"], "->", g["fix"]) ``` ### `shark.space_defaults.get` ```python shark.space_defaults.get() -> dict ``` The account's defaults for Spaces that do not set `account_secrets` / `github`, and `effective` (what an sdk and a devbox Space get). ### `shark.space_defaults.set` ```python shark.space_defaults.set(*, account_secrets=UNSET, github=UNSET) -> dict ``` `account_secrets`: `"all"`, `"none"` or `None` (back to the profile's). `github`: `True`, `False` or `None`. Needs `secrets:write`. ## Egress **Beta.** Per-Space outbound policy for the Space's commands and its agent's session VM: **profiles** (Shark's named, versioned host lists: `shark.egress.profiles()`) + the Space's own `allow` hosts − its `deny` hosts (deny wins). Modes `allowlist` (default), `deny`, `open` (only accounts allowed it: `PermissionDeniedError` `plan_restriction`). Private and link-local ranges are always refused. Defaults: SDK Spaces start with the `coding` profile (package registries, GitHub, Ubuntu mirrors: the base every Space had), devbox Spaces with `essentials` (plus GitLab and Docker Hub). At create: `egress={"profiles": [...], "allow": [...], "deny": [...]}`, or a name (`"deny"`, `"open"`, `"coding"`, any profile). An agent start adds its harness's profile by itself; `space.secrets.set(…, preset=P, allow_egress=True)` adds P's profile. Applies from the next command; forks inherit it. Writes need `network:write`; a refused entry is `InvalidRequestError` with `details` `field` / `host` / `profile` / `reason` (`unknown_profile`, `included_by`, `bad_name`, …). Details: [api.md](api.md#secrets-and-egress), [concepts.md](concepts.md#egress-policy). ```python sp = shark.spaces.create(egress={"profiles": ["coding", "anthropic-api"], "allow": ["db.example.com:5432"]}) sp.egress.use("+openrouter", "-pypi") # one atomic change; -pypi also takes it out of coding for e in sp.egress.get()["effective"]["entries"]: print(e["host"], e["from"]) # e.g. pypi.org ['coding/pypi'], db.example.com:5432 ['space'] for d in sp.egress.denials(since="1h"): print(d["host"], d["count"], d["suggest"]) # what was refused, and the profile that has it ``` ### `shark.egress.profiles` ```python shark.egress.profiles() -> List[dict] ``` Every profile: `name`, `title`, `kind`, `about`, `includes`, `hosts` (`host`, `why`, `verified`: `binary:@`, `probe`, `docs:…`, `legacy`), `all_hosts`, `source`, `checked`, `not_included` (left out on purpose), `harness`. ### `space.egress.get` ```python space.egress.get() -> dict ``` The policy: `mode`, `profiles`, `allow`, `deny`, `auto` (profiles Shark added by itself), `auto_profiles`, `base` (old field), `source` (`space` | `default` | `host`), `profiles_version`, `effective` (`hosts`, `entries` with `from`, `denied`). ### `space.egress.set` ```python space.egress.set(*, mode=None, profiles=None, allow=None, deny=None) -> dict ``` Replaces the policy (`PUT`); a field left `None` keeps its current value. ### `space.egress.use` ```python space.egress.use(*changes) -> dict ``` `"+name"` adds a profile, `"-name"` removes one (also out of a composite: `"-npm"` under `essentials`; `"+open"` / `"-open"` switch mode open). Removing a part of a harness profile is `InvalidRequestError` `included_by`: deny its hosts instead. ### `space.egress.allow` ```python space.egress.allow(*hosts) -> dict ``` Adds hosts of the Space's own (`example.com`, `*.example.com`, `db.example.com:5432`, `203.0.113.0/24:5432`). ### `space.egress.disallow` ```python space.egress.disallow(*hosts) -> dict ``` Removes hosts of the Space's own; one that is not listed comes back in `not_listed`. ### `space.egress.deny` ```python space.egress.deny(*hosts) -> dict ``` Refuses hosts (`name` or `*.suffix`, every port) even when a profile or an allow entry has them. ### `space.egress.undeny` ```python space.egress.undeny(*hosts) -> dict ``` Drops deny entries; one that is not listed comes back in `not_listed`. ### `space.egress.denials` ```python space.egress.denials(since="24h") -> List[dict] ``` Hosts the egress proxy refused (`host`, `port`, `count`, `first`, `last`, `kinds`, `suggest`: the profiles that have it, `add`: the CLI command that allows it). Host names and ports only, never content. `since`: `"1h"`, `"30m"`, `"2d"` or an RFC 3339 time. ## GitHub Two ways to give a Space GitHub access: - **Your account's connection** (a person connects once with `shark github connect`): `github=True` on the Space ([cli.md](cli.md#github)). - **Your own GitHub App** (a platform whose users install *your* App; control-plane mode only, otherwise `SharkError(code="needs_control_plane")`): register it once, then link each Space to one installation. ### `shark.github_app.register` ```python shark.github_app.register(app_id, private_key) -> dict ``` `private_key`: the App's PEM (`str` or `bytes`), sealed by the account service and never returned. Returns `{registered, app_id, slug, verified}`. Needs `secrets:write`; a pair GitHub refuses is `github_app_refused`. ### `shark.github_app.status` ```python shark.github_app.status() -> dict ``` `{registered, app_id, slug, linked_spaces}`, never the key. ### `shark.github_app.remove` ```python shark.github_app.remove() -> dict ``` Forgets the App and every Space link to it. ### `shark.github_app.link` ```python shark.github_app.link(space, installation_id, repos=None) -> dict ``` Stores a token of that installation (narrowed to `repos`) as the Space's `GITHUB_TOKEN` (github preset, inject); the Space's node keeps it fresh. Returns the metadata and `"secret": "GITHUB_TOKEN"`, never the token. Errors (`code`): `github_app_not_registered`, `github_not_connected` (not an installation of your App), `github_no_access` (a repository outside it). ```python shark.github_app.register(123456, open("app.pem").read()) shark.github_app.link(space, installation_id=7890, repos=["site"]) space.run("git clone https://github.com/acme/site && cd site && git push origin HEAD:try-1") ``` ## Usage, quotas and balance Usage is counts and durations only (never command text, paths or contents). Live caps: `shark.limits()`. What is metered: [billing.md](billing.md). ### `shark.usage.get` ```python shark.usage.get(start=None, end=None, group_by="day") -> dict ``` **Beta.** Rows of `commands`, `command_s`, `command_vcpu_s`, `service_awake_s`, `process_awake_s`, `storage_gib_h`, `egress_bytes`; `group_by`: `"day"`, `"space"` or `"kind"`; `start` / `end`: ISO dates. Needs `usage:read`. ### `shark.usage.balance` ```python shark.usage.balance() -> dict ``` The prepaid balance in micro-units: `balance_micro`, `paid_micro`, `promo_micro` (control-plane mode; direct mode raises `SharkError(code="config")`). ## API keys **Beta.** Need the `admin` scope. ### `shark.keys.list` ```python shark.keys.list() -> List[dict] ``` Never the secret. ### `shark.keys.create` ```python shark.keys.create(name="", scopes=None, idempotency_key=None) -> dict ``` The new key's secret is in this response only: store it in your secret manager, never in code or a Space. ### `shark.keys.rotate` ```python shark.keys.rotate(key_id, overlap_s=86400) -> dict ``` A new secret; the old one keeps working for `overlap_s` seconds. ### `shark.keys.revoke` ```python shark.keys.revoke(key_id) -> None ``` ## Async client `AsyncShark` has the same attributes and method names as `Shark`, as awaitables: each call runs the synchronous client in a thread pool, so a fan-out over many Spaces sends that many requests at once (the server's caps, `429` with `Retry-After`, bound the real concurrency). `stream()`, `events()` and `watch()` return the same blocking iterators as the sync client: iterate them in a thread, or use `run(..., on_stdout=…)`. ### `AsyncShark` ```python AsyncShark(*a, max_concurrency=64, **kw) ``` Takes `Shark`'s arguments plus `max_concurrency` (threads). Attributes: `spaces`, `secrets`, `space_defaults`, `github_app`, `keys`, `usage`. Usable as `async with AsyncShark() as shark:`. ```python import asyncio from shark import AsyncShark async def main(): async with AsyncShark() as shark: base = await shark.spaces.create(name="base") forks = await base.fork(count=8) results = await asyncio.gather(*(f.run(f"python solve.py --seed {i}") for i, f in enumerate(forks))) print([r.exit for r in results]) asyncio.run(main()) ``` ### `ashark.me` ```python ashark.me() -> dict ``` ### `ashark.capabilities` ```python ashark.capabilities() -> dict ``` ### `ashark.limits` ```python ashark.limits() -> List[dict] ``` ### `ashark.aclose` ```python ashark.aclose() -> None ``` Shuts the thread pool down. ### `ashark.transport` ```python ashark.transport -> Transport ``` ## Framework integrations Optional extras (`shark-sdk[langchain]`, `[langgraph]`, `[smolagents]`, `[prefect]`) under `shark.integrations`: `SharkExecTool`, `SharkReadFileTool`, `SharkWriteFileTool`, `shark_tools` (LangChain); `thread_space`, `branch_spaces`, `thread_id_of`, `task_key` (LangGraph); `SharkExecutor`, `SharkCodeAgent` (smolagents); `task_fork`, `task_keys`, `task_op_id` (Prefect); and the shared helpers `op_id_for`, `space_for`, `forked`, `fork_many`, `format_result`, `label_value`. Usage and what was tested: [integrations.md](integrations.md). ## Not in the SDK yet Services (`.shark/services.toml` instead), snapshots, webhooks and events, Space-scoped tokens and sub-accounts answer `422 unsupported` on the node ([api.md](api.md#coming-answer-422-unsupported-today)); the terminal is coming. Durable replication status (`GET /spaces/{id}/durable`) has no method: `space.settings()["durable"]`. --- # TypeScript SDK reference Every public class and method of the TypeScript SDK (version 0.1.0): signature, options, return type, the errors it throws, and a short example. ESM with type declarations, no dependencies; runs anywhere with `fetch` and `ReadableStream` (Node 22.18+, Bun, Deno, edge runtimes). Checked against the code by `proofs/a12-docs-acceptance/sdkcheck.py` (it fails when this page and `sdk/typescript/src` drift apart). The Python SDK, which has a few more helpers ([differences](#differences-from-the-python-sdk)): [sdk-python.md](sdk-python.md). The HTTP routes behind each call: [api.md](api.md#route--cli--sdk). Status follows [api.md](api.md): **GA-path** (served and tested), **beta** (served; local-only tests or a gap noted), **coming** (answers `422 unsupported` today). A method has the status of the route it calls; it is marked below where it is not GA-path. Options are one object per call; keys you leave out are not sent. In the signatures below, `options?: { a, b }` lists every key the code accepts (their types are in the text). ## Install ```sh ``` The package is not on npm yet; the placeholder is explained in [quickstart.md](quickstart.md#0-what-you-need). ## Authentication Put your **account key** (`shk_live_…`) in the environment and construct the client with no arguments: ```sh export SHARK_API_KEY=... # never in code, in a file of the Space, or in an agent's prompt ``` ```ts import { Shark } from "@sharkmagic/sdk"; const shark = new Shark(); ``` The client sends the key only to the account service (`https://api.sharkmagic.si`), which returns a 5-minute node token; the SDK refreshes it 60 s before it expires (one refresh in flight), finds the node that holds each Space, and sends the call there. The key never reaches a node. The TypeScript SDK reads `SHARK_API_KEY` (from `process.env` or `Deno.env`) or `apiKey`; it does not read `~/.shark/credentials`. **Leave `SHARK_API` unset.** `SHARK_API` (or `baseUrl`) is *direct mode*, for a self-hosted node and that node's own keys; an account key there answers `401 unauthenticated`. Mode order: `controlPlane` > `baseUrl` > `$SHARK_CP` > `$SHARK_API` > `https://api.sharkmagic.si`. ## Errors Every API error is thrown as a subclass of `SharkError` (all exported) with: `code` (the stable code from [errors.md](errors.md); branch on it), `status` (HTTP), `message`, `requestId`, `details`, `retryable`, `retryAfter` (seconds, from `Retry-After`). A code the SDK does not know maps by HTTP status (400 → `InvalidRequestError`, 401 → `AuthenticationError`, 402 → `CreditsExhaustedError`, 403 → `PermissionDeniedError`, 404 → `NotFoundError`, 409/412 → `ConflictError`, 410 → `OpExpiredError`, 413 → `InvalidRequestError`, 422 → `UnsupportedError`, 429 → `RateLimitError`, 5xx → `ServerError`). **Retries are built in.** Network errors, `429` and `5xx` marked retryable are retried (up to `maxRetries`, honouring `Retry-After`; `lease_cap_reached` keeps waiting up to `queueTimeoutMs`), always with the same `op_id` or `Idempotency-Key`, so a command never runs twice and a create never makes two Spaces. Only the final error reaches you. **A non-zero exit is not an exception**: `run()` resolves with it in `exit` (unless `check: true`). Argument mistakes in secrets (an `exec` secret with `hosts`, an `inject` secret without `hosts` or `preset`, an unknown preset) throw a plain `Error` before any request. | class | extends | API codes | thrown when | |---|---|---|---| | `SharkError` | `Error` | | base of all; also `code: "config"` (no API key, bad URL, `usage.balance()` in direct mode), `code: "needs_control_plane"` (`githubApp` in direct mode) | | `AuthenticationError` | `SharkError` | `unauthenticated`, `auth_rate_limited` | bad, revoked or expired key; account key in direct mode | | `PermissionDeniedError` | `SharkError` | `insufficient_scope`, `plan_restriction`, `tenant_not_allowed` | the key lacks a scope (`details.required`) or the account the feature | | `NotFoundError` | `SharkError` | `not_found` | unknown id or name, or another account's Space | | `ConflictError` | `SharkError` | `conflict`, `precondition_failed` | state does not allow it; `ifMatch` mismatch | | `IdempotencyMismatchError` | `ConflictError` | `idempotency_mismatch`, `idempotency_in_progress` | an `op_id` / key reused for a different request | | `OpExpiredError` | `SharkError` | `op_expired` | the op's stored output is past its 7 days | | `InvalidRequestError` | `SharkError` | `invalid_request`, `invalid_op_id`, `path_outside_space`, `payload_too_large`, `upgrade_required` | fix the request (`details.field`, `details.max_s`) | | `UnsupportedError` | `SharkError` | `unsupported` | a route this node does not serve yet (`details.phase`) | | `RateLimitError` | `SharkError` | `rate_limited` | request rate per key, after the retries | | `LeaseCapError` | `RateLimitError` | `lease_cap_reached` | concurrent commands at the cap after `queueTimeoutMs`; the command did not start; `.scope` = `details.scope` | | `SpaceLimitError` | `RateLimitError` | `space_limit_reached` | the node's live-Space cap | | `QuotaExceededError` | `SharkError` | `quota_exceeded`, `insufficient_storage` | an account cap (`details.quota`: `spaces`, `awake`) or disk | | `CreditsExhaustedError` | `SharkError` | `credits_exhausted` | balance at zero; reads keep working | | `ServerError` | `SharkError` | `internal` | after the retries; quote `requestId` | | `CapacityError` | `ServerError` | `capacity_unavailable`, `out_of_capacity`, `canceled` | the node was too busy; **the command did not run** | | `DurableUnavailableError` | `ServerError` | `durable_unavailable` | `checkpoint()` could not reach the durable store | | `ConnectionError` | `SharkError` | | the network failed after the retries (`code: "connection"`) | | `CommandFailedError` | `SharkError` | | `run(cmd, { check: true })` and the exit is not 0 (`code: "command_failed"`; `.exit`, `.stdout`, `.stderr`, `.opId`) | | `InfraError` | `SharkError` | | `run()` got `shark_status: infra_error` twice for the same `op_id` (`code: "infra_error"`) | | `CommandTimeoutError` | `SharkError` | | `run()` got `shark_status` `timeout` or `infra_timeout` (`code` is that status) | ```ts import { Shark, QuotaExceededError, CreditsExhaustedError, SharkError } from "@sharkmagic/sdk"; try { await shark.spaces.create({ name: "job-1" }); } catch (e) { if (e instanceof QuotaExceededError) console.log(e.code, e.details.quota); // spaces | awake else if (e instanceof CreditsExhaustedError) console.log("top up; files stay readable"); else if (e instanceof SharkError) console.log(e.code, e.requestId); else throw e; } ``` ### `errorClass` ```ts errorClass(code: string, status: number): Ctor ``` The `SharkError` subclass (constructor) the SDK uses for an error code and HTTP status. `CODE_CLASSES` (code → class) is exported too. ## Client ### `new Shark` ```ts new Shark(options?: { apiKey, baseUrl, controlPlane, timeoutMs, maxRetries, queueTimeoutMs, fetch }) ``` `apiKey`: default `$SHARK_API_KEY`. `baseUrl`: a self-hosted node (direct mode). `controlPlane`: the account service URL (default `https://api.sharkmagic.si`, or `$SHARK_CP`). `timeoutMs`: per HTTP request, default 60,000 (commands get their own). `maxRetries`: default 5. `queueTimeoutMs`: how long to keep retrying `lease_cap_reached`, default 600,000. `fetch`: your own `fetch` implementation. Throws `SharkError` (`code: "config"`) without a key or with a bad URL (plain `http` only to loopback). Members: `spaces`, `secrets` (account secrets), `spaceDefaults`, `githubApp`, `keys`, `usage`. ### `shark.me` ```ts shark.me(): Promise> ``` Your account (`tenant`), plan, and the credential's id, kind, scopes and expiry (`GET /me`). ### `shark.capabilities` ```ts shark.capabilities(): Promise> ``` The node's API version, features, limits and lease shapes. Read features from here rather than assuming them. ### `shark.limits` ```ts shark.limits(): Promise ``` Every limit that applies to you with current use: `[{name, scope, limit, used}, …]` (`GET /quotas`). Read limits from here, never from memory ([limits.md](limits.md)). ### `shark.transport` ```ts shark.transport: Transport ``` The low-level transport (`transport.request(operationId, { params, query, json })` sends one call of `ROUTES` with the retry policy above; `transport.stats` counts requests, retries and reconnects). Not covered by this reference; prefer the methods below. ### `newOpId` ```ts newOpId(): string ``` A fresh time-ordered id (UUIDv7 layout). The SDK makes one per call when you pass none; in an agent loop pass your tool-call id as `opId` instead, so a retried tool call replays rather than runs twice. ### `parseSse` ```ts parseSse(body: ReadableStream): AsyncGenerator ``` Parses a `text/event-stream` body into events (for calls you make through `transport.request`). ## Spaces A Space is a persistent workspace; each command runs in a fresh microVM with it mounted ([concepts.md](concepts.md#space)). **SDK Spaces.** Spaces the SDK creates are **sdk** Spaces: nothing of your account reaches them unless the Space asks. Every option below can be set at create, changed with `space.update(...)`, and overridden per fork; leave it out for the default, pass `null` to `update` to go back to the profile's default ([api.md](api.md#sdk-spaces-and-devbox-spaces-profile)). | option | sdk default | values | |---|---|---| | `profile` | `"sdk"` | `"sdk"` or `"devbox"` (as `shark create` / `shark code`: the account's secrets and GitHub, background jobs, the Space CLI); create only | | `accountSecrets` | `"none"` | `"all"` (the account's `apply: all` secrets), `"none"` (only those the Space references), or an array of names | | `github` | `false` | `true`: the account's GitHub connection (a managed `GITHUB_TOKEN`; connect it once with `shark github connect`, [cli.md](cli.md#github)) | | `git` | none | `{ name, email }`: the Space's commit identity | | `spaceCli` | `false` | `true`, or an array of scopes (subset of `spaces:read`, `files:read`, `exec`, `usage:read`): a Space credential for the `shark` CLI inside the Space | | `keepBackground` | `false` | `true`: a process a command leaves running (`cmd &`) stays as a job (beta, [background jobs](#background-jobs-processes)) | | `keepAwake` | `false` | `true`: kept jobs never sleep when idle (**billed awake** the whole time) | | `egress` | node default | a policy object, or `"deny"`, `"coding"` (package registries and code hosts), `"open"` ([egress](#egress)) | | `labels` | none | `{ k: "v" }`; `shark.spaces.list({ label: "k:v" })` filters | Changing `accountSecrets`, `github` or `git` needs the `secrets:write` scope; `keepBackground`, `keepAwake` and `spaceCli` need `exec`; `egress` needs `network:write`. A refused value is `InvalidRequestError` with `details.field`, and nothing of the request is applied. ### `shark.spaces.create` ```ts shark.spaces.create(options?: { name, labels, egress, env, fromSpace, idempotencyKey, profile, accountSecrets, github, git, keepBackground, keepAwake, spaceCli }): Promise ``` Creates a Space (`POST /spaces`). `name`: unique in your account, usable in place of the id. `env`: non-secret variables for every command. `fromSpace`: start from another Space's content, O(1). `idempotencyKey`: generated if absent; the SDK retries with the same key, so a retried create never makes two Spaces. Throws `QuotaExceededError` (`details.quota` `spaces` or `awake`), `CreditsExhaustedError`, `CapacityError`, `ConflictError` (duplicate name), `InvalidRequestError`. ```ts const space = await shark.spaces.create({ name: "acme-1", labels: { customer: "acme" }, egress: "coding", accountSecrets: ["OPENAI_API_KEY"], git: { name: "Acme Bot", email: "bot@acme.test" } }); ``` ### `shark.spaces.get` ```ts shark.spaces.get(idOrName: string): Promise ``` One Space by id (`tti-…`) or name; does not wake it. Throws `NotFoundError`. ### `shark.spaces.list` ```ts shark.spaces.list(options?: { label, state, name }): AsyncGenerator ``` Every matching Space, newest first, pages followed; does not wake hibernated ones. `label`: `"k:v"` or an array (all must match). `state`: `"ready"` or `"hibernated"`. ```ts for await (const s of shark.spaces.list({ label: "customer:acme" })) console.log(s.id, s.state); ``` ### `space.id` ```ts space.id: string ``` The Space id (`tti-<32 hex>`). ### `space.state` ```ts space.state: string ``` `ready` or `hibernated`, as of the last response (`space.refresh()` to update). ### `space.labels` ```ts space.labels: Record ``` ### `space.profile` ```ts space.profile: string ``` `sdk` or `devbox` (Spaces made before profiles report `devbox`). ### `space.refresh` ```ts space.refresh(): Promise ``` Re-reads the Space, including `settings`. `space.data` holds the raw JSON. ### `space.settings` ```ts space.settings(): Promise> ``` Every per-Space setting with its source: `name`, `labels`, `env`, `egress` (with `effective.hosts`), `dormancy`, `secrets` (names only), `ports`, `durable`, `profile`, `account_secrets`, `github`, `git`, `keep_background`, `keep_awake`, `space_cli` (each `{value, source}`; the keys are the API's snake_case), `limits` ([api.md](api.md#spaces)). ### `space.update` ```ts space.update(options?: { name, labels, env, egress, accountSecrets, github, git, keepBackground, keepAwake, spaceCli }): Promise ``` Changes settings in one `PATCH /spaces/{id}`: every field is validated before any is applied. `labels` and `env` replace the whole map. `null` for an account option sets it back to the profile's default. Throws `InvalidRequestError` (`details.field`), `PermissionDeniedError` (`insufficient_scope`, or `plan_restriction` for egress `open`). ```ts await space.update({ github: true, keepBackground: null }); ``` ### `space.fork` ```ts space.fork(options?: { count, labels, inheritLabels, namePrefix, at, idempotencyKey, accountSecrets, github, git, keepBackground, keepAwake, spaceCli }): Promise ``` `count` (1–50, default 1) copies at HEAD (or at commit `at`): files O(1); `env` and egress copied; the Space's own secrets, processes and service memory not (account secrets reach a fork as any Space of the account). Forks keep the source's profile and account options unless you pass them. `inheritLabels: false`: the forks get only `labels`. Retried with one `Idempotency-Key`. Throws `QuotaExceededError`, `InvalidRequestError` (count), `NotFoundError`. ```ts const attempts = await space.fork({ count: 3, labels: { try: "1" } }); const exits = await Promise.all(attempts.map(async (f) => (await f.run("npm test")).exit)); ``` ### `space.hibernate` ```ts space.hibernate(): Promise ``` Hibernates now (any later request wakes it). Throws `ConflictError` while something runs. ### `space.wake` ```ts space.wake(): Promise> ``` Wakes now; the result has `wake_ms`. Not needed before a command: any request wakes the Space. ### `space.checkpoint` ```ts space.checkpoint(): Promise> ``` Resolves once HEAD and its objects are in the durable store. Throws `DurableUnavailableError`. ### `space.delete` ```ts space.delete(options?: { purge }): Promise ``` Waits for the running command, then deletes the Space; `purge: true` also deletes the durable copy. Deleting a Space that is already gone is not an error. ### `space.audit` ```ts space.audit(): Promise> ``` Commits from HEAD with their `op_id`, changed paths and exit. ## Commands Three ways to run a command, all exactly once per `op_id` ([concepts.md](concepts.md#op-and-op_id-exactly-once)): `run` waits (sync; server default 120 s, max 900 s), `stream` yields live events, `start` returns an `Op` at once (stream and async: default 1,800 s, max 7,200 s). A `timeoutMs` above the maximum is `InvalidRequestError` with `details.max_s`. The options (`RunOptions`, shared by the three): | option | meaning | |---|---| | `timeoutMs` | how long the command may run (sent as seconds) | | `cwd` | directory relative to the Space root | | `env` | non-secret variables for this command | | `stdin` | `string` or `Uint8Array` (≤ 1 MiB) | | `opId` | the command's id (1–64 of `[A-Za-z0-9_.:-]`); generated if absent. Use your tool-call id | | `background` | `true`: what the command leaves running (`cmd &`) stays as a job; `false`: it ends with the command; unset: the Space's `keepBackground` (sdk: off) | | `secrets` | names of the Space's `exec`-exposed secrets to put in this command's environment | | `check` | `run` only: throw `CommandFailedError` on a non-zero exit | | `onStdout`, `onStderr` | `run` only: callbacks with each chunk of text as it arrives (the call then streams) | | `signal` | `run` only (sync): an `AbortSignal` that aborts the HTTP wait | | `idleTimeoutMs` | `stream` (and `run` with callbacks): silence before reconnecting, default 45,000 (the server pings every 15 s) | ### `space.run` ```ts space.run(cmd: string, options?: { timeoutMs, cwd, env, stdin, opId, check, onStdout, onStderr, signal, idleTimeoutMs, secrets, background }): Promise ``` Runs one command and waits. An `infra_error` is retried once with the same `opId`; a node too busy to start the command is retried after its `Retry-After`. Throws `InfraError`, `CommandTimeoutError`, `CapacityError` (still busy: the command did not run), `CommandFailedError` (with `check`), and the API errors (`LeaseCapError`, `CreditsExhaustedError`, `NotFoundError`, …). ```ts const r = await space.run("npm ci && npm test", { cwd: "web", env: { CI: "1" }, timeoutMs: 900_000, opId: toolCall.id }); console.log(r.exit, r.sharkStatus, r.stdout.slice(-2000)); ``` ### `space.stream` ```ts space.stream(cmd: string, options?: { timeoutMs, cwd, env, stdin, opId, check, onStdout, onStderr, signal, idleTimeoutMs, secrets, background }): AsyncGenerator ``` Live events: `start`, `stdout`, `stderr`, `detached`, then `exit` (the result) or `error`. A dropped connection resumes from the last event id: each event is yielded exactly once. Throws `ConnectionError` when the stream cannot be resumed. ```ts for await (const ev of space.stream("make -j8")) { if (ev.type === "stdout" || ev.type === "stderr") process.stdout.write(ev.text); else if (ev.type === "exit") console.log("exit", ev.data.exit); } ``` ### `space.start` ```ts space.start(cmd: string, options?: { timeoutMs, cwd, env, stdin, opId, check, onStdout, onStderr, signal, idleTimeoutMs, secrets, background }): Promise ``` Starts the command and returns its `Op` at once (`"wait": false`, `202`). Come back later, from this or any other process, with `space.op(opId)`. ```ts const op = await space.start("python train.py", { timeoutMs: 7_200_000 }); // later, anywhere: const res = await (await space.op(op.opId)).wait(); ``` ### `space.op` ```ts space.op(opId: string): Promise ``` Re-attaches to an op by id. Throws `NotFoundError`, `OpExpiredError` (output past 7 days). ### `space.ops` ```ts space.ops(options?: { state, limit }): Promise ``` Recent ops, newest first (`state`: `"running"` or `"done"`). ### `op.refresh` ```ts op.refresh(): Promise ``` Re-reads the op into `op.data` (`state`, `cmd`, `result`). `op.opId` and `op.space` are fields. ### `op.events` ```ts op.events(after?): AsyncGenerator ``` Every event after event id `after` (default 0), live or after the op ended (kept 7 days), each exactly once. ### `op.wait` ```ts op.wait(): Promise ``` Follows the event stream to the end and returns the result. ### `op.signal` ```ts op.signal(sig?): Promise ``` Sends `"TERM"` (default), `"INT"`, `"KILL"` or `"HUP"` to the running command. ### `ExecResult` | field | meaning | |---|---| | `opId` | the command's id | | `exit` | the exit code (non-zero is a normal result) | | `sharkStatus` | `ok`, `conflict` (re-run with a new `opId`), `timeout`, `infra_timeout`, `infra_error`, `capacity_unavailable`, `signaled` ([concepts.md](concepts.md#lease-one-command-one-microvm)) | | `stdout`, `stderr` | output (sync: the last 1 MiB of each stream) | | `stdoutTruncated` | `true` when stdout was cut; `op.events()` has all of it | | `commit` | the commit the command made, or `null` when it changed no file | | `changedPaths` | number of paths changed | | `wallMs` | time at the node, end to end | | `queueMs` | time waiting for a slot | | `wakeMs` | set when the request woke the Space | | `runS` | the command's own run time | | `replayed` | `true` when this is the stored result of an earlier request with the same `opId` | | `raw` | the response JSON (`raw.stderr_truncated`, `raw.processes`, …) | ### `SseEvent` | field | meaning | |---|---| | `id` | the event's sequence number (resume point) | | `type` | `start`, `stdout`, `stderr`, `detached`, `exit`, `error` | | `data` | the event's JSON (`data.exit` on `exit`) | | `text` | a `stdout`/`stderr` chunk as text (base64 chunks decoded) | ## Files Reads come from the committed tree **without starting a VM** (and without waking a hibernated Space). Writes are ops: one commit each, exactly once per `opId`; a write that races a command on the same path is `ConflictError` (`details.paths`). Paths are relative to the Space root; one that escapes it is `InvalidRequestError` (`path_outside_space`). `at`: read at a past commit. ### `space.files.read` ```ts space.files.read(path: string, at?: string): Promise ``` Throws `NotFoundError`. ### `space.files.readText` ```ts space.files.readText(path: string, at?: string): Promise ``` ```ts console.log(await space.files.readText("out/report.json")); ``` ### `space.files.write` ```ts space.files.write(path: string, data: string | Uint8Array, options?: { mode, opId, ifMatch }): Promise> ``` Writes one file (≤ 512 MiB) as one commit; parents are created. `mode`: e.g. `"0755"`. `ifMatch`: an etag from `stat()`: compare-and-swap, `ConflictError` (`precondition_failed`) if the file changed. ### `space.files.stat` ```ts space.files.stat(path: string): Promise> ``` `{type, size, mode, mtime, etag, commit}` without the content (all strings). Throws `NotFoundError`. ### `space.files.list` ```ts space.files.list(path?: string, options?: { depth, glob, at }): Promise ``` The tree under `path` (default `"/"`), pages followed. `glob`: e.g. `"**/*.ts"`. ### `space.files.remove` ```ts space.files.remove(path: string, options?: { recursive, opId }): Promise> ``` ### `space.files.move` ```ts space.files.move(from: string, to: string, options?: { overwrite, opId }): Promise> ``` ### `space.files.uploadArchive` ```ts space.files.uploadArchive(path: string, tar: Uint8Array, options?: { replace, opId }): Promise> ``` Uploads a tar (plain, gzip or zstd, ≤ 2 GiB) into the directory `path` as one commit. `replace: true`: the directory becomes exactly the archive. ### `space.files.downloadArchive` ```ts space.files.downloadArchive(path: string, options?: { plain, at }): Promise ``` The directory as a tar (`plain: true` asks for an uncompressed one). ### `FileEntry` | field | meaning | |---|---| | `path` | relative to the Space root | | `type` | `file`, `dir` or `symlink` | | `size` | bytes | | `mode` | e.g. `0644` | | `mtime` | modification time | | `hash` | content hash | | `target` | a symlink's target | ## Background jobs (processes) **Beta.** On a node that keeps background jobs, a process a command leaves running (with `background: true`, or a Space with `keepBackground: true`) becomes a job `bg-1`, `bg-2`, … in its own VM; later commands reach its ports at `127.0.0.1:` ([concepts.md](concepts.md#background-processes)). **A job is billed (process seconds) while it is awake**, and with `keepAwake: true` it never sleeps: stop jobs you no longer need. The TypeScript SDK lists jobs; to read a job's log or stop it, use the Python SDK (`space.processes.logs/signal`), the CLI (`shark logs`, `shark kill`), or `POST /v1/spaces/{id}/processes/{pid}/signal`. ### `space.processes` ```ts space.processes(): Promise ``` Jobs: `id` (`bg-1`), `op_id`, `state` (`running`, `paused`, `snapshotted`, `exited`), `ports`, `exit`. ```ts await space.run("(timeout 1800 node server.js > srv.log 2>&1 &)", { background: true }); console.log(await space.processes()); ``` ## Exposed processes and previews **Live on the hosted node** (beta). A server declared on a port starts on the first request, sleeps after `idleS` (default 30 s) without traffic, wakes on the next request, follows HEAD and is reachable at `https://-.preview.sharkmagic.si`. **Private by default**: only a browser that opened a one-time login link from the account sees it. An exposed process is **billed (service seconds) while it is awake**, by size, and not while asleep: remove the ones you no longer need; `shark.ports.list({ state: "running" })` finds what is awake. The account caps how many run at once (`awake_services` in `shark.limits()`). A node without the ports runner throws `UnsupportedError` ([api.md](api.md#exposed-processes-previews-and-the-terminal)). ### `space.ports.declare` ```ts space.ports.declare(port: number, cmd: string, options?: { cwd, env, idleS, public }): Promise ``` Declares (or replaces, restarting it) the server on `port`; returns the exposure (`port`, `state`, `url`, ...). `env` is for non-secret values. ```ts const p = await space.ports.declare(3000, "npm run dev", { cwd: "app", idleS: 60 }); const { login_url } = await space.ports.loginLink(3000); // hand it to a person; it works once ``` ### `space.ports.list` ```ts space.ports.list(): Promise ``` Every exposure of the Space: `port`, `state` (`declared`, `starting`, `running`, `asleep`, `exited`, `failed`), `url`, `cmd`, `cwd`, `idle_s`, `public`, `connections`, `last_request_at` (null = no request since the node started), `awake_since` (null unless running), `exit_code`, `error`. ### `space.ports.get` ```ts space.ports.get(port: number): Promise ``` ### `space.ports.setPublic` ```ts space.ports.setPublic(port: number, isPublic: boolean): Promise ``` `true`: anyone with the URL (every request can wake it); `false`: one-time links only. `PermissionDeniedError` (`plan_restriction`) where public previews are off. ### `space.ports.sleep` ```ts space.ports.sleep(port: number): Promise ``` Sleeps now; the next request wakes it. ### `space.ports.logs` ```ts space.ports.logs(port: number): Promise ``` ### `space.ports.remove` ```ts space.ports.remove(port: number): Promise ``` Stops it and revokes every preview cookie. ### `space.ports.loginLink` ```ts space.ports.loginLink(port: number, options?: { path }): Promise ``` `{url, public, login_url, expires_at}`: `login_url` logs in whoever opens it, once, within 60 s. Never store, log or paste it. ### `shark.ports.list` ```ts shark.ports.list(options?: { state }): Promise ``` Every exposure of the account (`GET /ports`), awake first, with `space`, `space_name` and `awake`. `state`: a state or an array of them. ## Secrets **Beta.** Values are write-only: no call returns one. Default exposure is `inject`: commands see a placeholder `shk_ph_…` and the node's egress proxy puts the real value only into requests to the secret's `hosts`; **the value never enters the VM**. `exec: true` puts the value in the environment of commands that list it in `secrets: [...]` (the response warns `secret_exposed_to_code`). `in`: where the value goes: `"bearer"` (default), `"basic"`, `"header:NAME"`, `"query:PARAM"`, or `{ kind, name }`. `preset`: `"anthropic"`, `"github"`, `"npm"`, `"openai"` fill `hosts` and `in` ([concepts.md](concepts.md#secrets-inject-default-vs-exec)). Writing secrets needs `secrets:write`. `space.secrets` holds the Space's own secrets and references to account secrets; `shark.secrets` holds the account's (set once; `apply: "all"` reaches every Space whose `accountSecrets` takes it, `apply: "ref"` only Spaces that reference it). A Space secret wins over an account secret of the same name. ### `space.secrets.set` ```ts space.secrets.set(name: string, value: string, options?: { preset, hosts, in, exec, allowEgress }): Promise ``` Stores a Space secret; resolves `{names, warnings}` (e.g. `scope_host_not_allowed`). `allowEgress: true` also adds the hosts to the Space's egress allowlist. ```ts await space.secrets.set("STRIPE_KEY", key, { hosts: ["api.stripe.com"] }); await space.secrets.set("GITHUB_TOKEN", tok, { preset: "github", allowEgress: true }); // your own GitHub token await space.secrets.set("DB_URL", url, { exec: true }); await space.run("node migrate.js", { secrets: ["DB_URL"] }); ``` ### `space.secrets.reference` ```ts space.secrets.reference(name: string): Promise ``` Uses the account secret of this name in this Space (how an sdk Space with `accountSecrets: "none"` takes one). A name with no account secret is accepted with the warning `account_secret_not_found`. ### `shark.secrets.set` ```ts shark.secrets.set(name: string, value: string, options?: { preset, hosts, in, exec, apply }): Promise ``` Stores an account secret (`PUT /secrets`). `apply`: `"all"` (default) or `"ref"`. ```ts await shark.secrets.set("OPENAI_API_KEY", key, { preset: "openai", apply: "ref" }); ``` The methods below exist on both `space.secrets` and `shark.secrets`. ### `secrets.presets` ```ts secrets.presets(): Promise ``` The provider catalog: `name`, `suggested_name`, `hosts`, `in`, `note`. ### `secrets.list` ```ts secrets.list(): Promise ``` Names, `expose`, `hosts`, `in`, `source` (`space` or `account`), `shadowed`, `missing`, `excluded`, `apply`: never values. ### `secrets.get` ```ts secrets.get(name: string): Promise ``` One entry of `list()` (the one in effect). Throws `NotFoundError`. ### `secrets.rotate` ```ts secrets.rotate(name: string, value: string): Promise ``` A new value; exposure, hosts and locations stay. Rotate an account secret with `shark.secrets.rotate`. ### `secrets.edit` ```ts secrets.edit(name: string, options?: { preset, hosts, in, exec, apply }): Promise ``` Changes hosts, locations, exposure or `apply` and keeps the stored value. ### `secrets.remove` ```ts secrets.remove(name: string): Promise ``` Removing a name that is not there is not an error. ### `secrets.usage` ```ts secrets.usage(name: string): Promise ``` Per host (and, for an account secret, per Space): `count`, `blocked`, `last_used`. ### `secrets.status` ```ts secrets.status(options?: { agents, presets }): Promise ``` What is missing and the fix, computed by the node (`GET /spaces/{id}/secrets/status`, `GET /secrets/status`; the `shark secrets status --json` document): `scope`, `secrets`, `gaps` (`code`, `severity`, `secret`, `agent`, `hosts`, `message`, `fix`) and `ok`. Gap codes: `missing_secret`, `egress_not_allowed`, `account_secret_unset` (errors), `shadowed`, `placeholder_blocked`, `egress_unknown` (info). Never a value. ### `shark.spaceDefaults.get` ```ts shark.spaceDefaults.get(): Promise ``` The account's defaults for Spaces that do not set `accountSecrets` / `github`, and `effective` (what an sdk and a devbox Space get). ### `shark.spaceDefaults.set` ```ts shark.spaceDefaults.set(options: { accountSecrets, github }): Promise ``` `accountSecrets`: `"all"`, `"none"` or `null` (back to the profile's). `github`: `true`, `false` or `null`. Needs `secrets:write`. ## Egress **Beta.** Per-Space outbound policy for the Space's commands and its agent's session VM: **profiles** (Shark's named, versioned host lists: `shark.egress.profiles()`) + the Space's own `allow` hosts − its `deny` hosts (deny wins). Modes `allowlist` (default), `deny`, `open` (only accounts allowed it: `PermissionDeniedError` `plan_restriction`). Private and link-local ranges are always refused. Defaults: SDK Spaces start with the `coding` profile, devbox Spaces with `essentials`. At create: `egress: { profiles: [...], allow: [...], deny: [...] }`, or a name (`"deny"`, `"open"`, `"coding"`, any profile). An agent start adds its harness's profile by itself; `space.secrets.set(…, { preset, allowEgress: true })` adds the preset's profile. Applies from the next command; forks inherit it. Writes need `network:write`. Details: [api.md](api.md#secrets-and-egress), [concepts.md](concepts.md#egress-policy). ```ts const sp = await shark.spaces.create({ egress: { profiles: ["coding", "anthropic-api"], allow: ["db.example.com:5432"] } }); await sp.egress.use("+openrouter", "-pypi"); // one atomic change; -pypi also takes it out of coding for (const e of (await sp.egress.get()).effective.entries) console.log(e.host, e.from); for (const d of await sp.egress.denials({ since: "1h" })) console.log(d.host, d.count, d.suggest); ``` ### `shark.egress.profiles` ```ts shark.egress.profiles(): Promise ``` Every profile: `name`, `title`, `kind`, `about`, `includes`, `hosts` (`host`, `why`, `verified`), `all_hosts`, `source`, `checked`, `not_included`, `harness`. ### `space.egress.get` ```ts space.egress.get(): Promise> ``` The policy: `mode`, `profiles`, `allow`, `deny`, `auto`, `auto_profiles`, `base` (old field), `source`, `effective` (`hosts`, `entries` with `from`, `denied`). ### `space.egress.set` ```ts space.egress.set(options?: { mode, profiles, allow, deny }): Promise> ``` Replaces the policy (`PUT`); a field left out keeps its current value. ### `space.egress.use` ```ts space.egress.use(...changes: string[]): Promise> ``` `"+name"` adds a profile, `"-name"` removes one (also out of a composite: `"-npm"` under `essentials`). ### `space.egress.allow` ```ts space.egress.allow(...hosts: string[]): Promise> ``` Adds hosts of the Space's own. ### `space.egress.disallow` ```ts space.egress.disallow(...hosts: string[]): Promise> ``` Removes hosts of the Space's own (`not_listed` for one that was not there). ### `space.egress.deny` ```ts space.egress.deny(...hosts: string[]): Promise> ``` Refuses hosts (`name` or `*.suffix`) even when a profile or an allow entry has them. ### `space.egress.undeny` ```ts space.egress.undeny(...hosts: string[]): Promise> ``` Drops deny entries. ### `space.egress.denials` ```ts space.egress.denials(options?: { since }): Promise ``` Hosts the egress proxy refused (`host`, `port`, `count`, `first`, `last`, `kinds`, `suggest`, `add`); never content. ## GitHub Two ways to give a Space GitHub access: - **Your account's connection** (a person connects once with `shark github connect`): `github: true` on the Space ([cli.md](cli.md#github)). - **Your own GitHub App** (a platform whose users install *your* App; control-plane mode only, otherwise `SharkError` with `code: "needs_control_plane"`): register it once, then link each Space to one installation. ### `shark.githubApp.register` ```ts shark.githubApp.register(appId: string | number, privateKey: string): Promise ``` `privateKey`: the App's PEM, sealed by the account service and never returned. Resolves `{registered, app_id, slug, verified}`. Needs `secrets:write`; a pair GitHub refuses is `github_app_refused`. ### `shark.githubApp.status` ```ts shark.githubApp.status(): Promise ``` `{registered, app_id, slug, linked_spaces}`, never the key. ### `shark.githubApp.remove` ```ts shark.githubApp.remove(): Promise ``` Forgets the App and every Space link to it. ### `shark.githubApp.link` ```ts shark.githubApp.link(space: Space | string, installationId: number, repos?: string[]): Promise ``` Stores a token of that installation (narrowed to `repos`) as the Space's `GITHUB_TOKEN` (github preset, inject); the Space's node keeps it fresh. Resolves the metadata and `secret: "GITHUB_TOKEN"`, never the token. Error codes: `github_app_not_registered`, `github_not_connected` (not an installation of your App), `github_no_access` (a repository outside it). ```ts await shark.githubApp.register(123456, pem); await shark.githubApp.link(space, 7890, ["site"]); ``` ## Usage, quotas and balance Usage is counts and durations only (never command text, paths or contents). Live caps: `shark.limits()`. What is metered: [billing.md](billing.md). ### `shark.usage.get` ```ts shark.usage.get(options?: { from, to, groupBy }): Promise ``` **Beta.** Rows of `commands`, `command_s`, `command_vcpu_s`, `service_awake_s`, `process_awake_s`, `storage_gib_h`, `egress_bytes`; `groupBy`: `"day"`, `"space"` or `"kind"`; `from` / `to`: ISO dates. Needs `usage:read`. ### `shark.usage.balance` ```ts shark.usage.balance(): Promise> ``` The prepaid balance in micro-units: `balance_micro`, `paid_micro`, `promo_micro` (control-plane mode; direct mode throws `SharkError` with `code: "config"`). ## API keys **Beta.** Need the `admin` scope. ### `shark.keys.list` ```ts shark.keys.list(): Promise ``` Never the secret. ### `shark.keys.create` ```ts shark.keys.create(options?: { name, scopes }): Promise ``` The new key's secret is in this response only: store it in your secret manager, never in code or a Space. ### `shark.keys.rotate` ```ts shark.keys.rotate(keyId: string, overlapS?: number): Promise ``` A new secret; the old one keeps working for `overlapS` seconds (default 86,400). ### `shark.keys.revoke` ```ts shark.keys.revoke(keyId: string): Promise ``` ## Differences from the Python SDK The same API underneath; these helpers exist only in Python today: `space.usage()`, `space.files.exists()`, `space.files.watch()`, `space.files.uploadDir/downloadDir` (TypeScript: `uploadArchive` / `downloadArchive` with a tar you build), `op.output()`, `space.processes.get/logs/signal` (TypeScript: `space.processes()` lists), `run`'s `retryConflict` and `requestTimeout`, `create`'s `at` and `tenant`, `Shark`'s `caFile` and `close()`, an async client (TypeScript is async already), reading `~/.shark/credentials`, and the framework integrations ([integrations.md](integrations.md)). ## Not in the SDK yet Services (`.shark/services.toml` instead), snapshots, webhooks and events, Space-scoped tokens and sub-accounts answer `422 unsupported` on the node ([api.md](api.md#coming-answer-422-unsupported-today)); the terminal is coming. --- # Errors Every `/v1` error has the same body, a stable `code`, and says whether a retry can succeed. ```json {"error": { "code": "lease_cap_reached", "message": "too many leases running: tenant cap reached; the command did not start", "retryable": true, "request_id": "req_01M6N8R52A3XZP6Z2DTKWFNRRW", "details": {"scope": "tenant", "limit": 6, "active": 6, "queued": 1, "waited_ms": 30012} }} ``` - **`code`** is stable; branch on it. The list is open: treat an unknown code by its HTTP status. - **`message`** is for humans and may change. - **`retryable`**: `true` means the same request may succeed later. Retryable `429` and `503` always carry a `Retry-After` header (integer seconds, 1–30), computed from your queue rather than fixed. - **`request_id`** is also in the `Shark-Request-Id` header of every response, errors or not. Quote it when you ask for help. - **`details`** carries the field at fault (`details.field`), limits (`limit`, `used`, `max_s`) or a scope. **A command that fails is not an HTTP error.** `exec` answers `200` with the command's `exit`; check `shark_status` for Shark-side outcomes (`conflict`, `timeout`, `infra_timeout`, `infra_error`, `signaled`: [concepts.md](concepts.md#lease-one-command-one-microvm)). **A busy node is not an infra error.** When the node's CPUs are saturated it refuses new work up front (`503 out_of_capacity`, `details.reason` = `cpu_pressure` or `cpu_runqueue`), and a command it admitted but could not start in time (its VM got no CPU to boot) ends as `shark_status: "capacity_unavailable"`, not `infra_error`. In both cases **the command did not run**: resend it with the **same** `op_id` after `Retry-After`. A sync `exec` gets that second case as `503 capacity_unavailable` (`details.reason` = `lease_start`, `details.op_id`); a stream or an async op gets it in the result (`exit` 125, `"retryable": true`, `"retry_after_s"`). The CLI and both SDKs retry both on their own. To see whether a node is admitting work right now: the `node_admission` row of `GET /v1/quotas` (`state` `open` or `closed`, `reason`, the CPU pressure it read). ## Codes | HTTP | `code` | retryable | when | what to do | |---|---|---|---|---| | 400 | `invalid_request` | no | malformed JSON or a bad field (`details.field`); also `timeout_s` above the mode's maximum (`details.max_s`, `details.mode`); `GET …/secrets/status` with an unknown `agent` or `preset` (`details.reason` = `unknown_agent` \| `unknown_preset`); `GET /ports` with an unknown `state`; an egress host adds `details.host` and `details.reason` (`ip_without_port`, `private_range`, `cidr_without_port`, `bad_cidr`, `bad_port`, `bad_name`, `invalid_host`, `too_many_entries`) | fix the request | | 400 | `invalid_op_id` | no | `op_id` (or `Shark-Op-Id`) is not 1–64 of `[A-Za-z0-9_.:-]` | | | 400 | `path_outside_space` | no | a path or `cwd` escapes the Space root, or touches reserved `.shark/` internals | use paths relative to the Space root | | 401 | `unauthenticated` | no | missing, malformed, revoked or expired credential; also an account key sent straight to a node (direct mode, `SHARK_API` set) instead of a node token | check `SHARK_API_KEY`; `unset SHARK_API`; raw HTTP: exchange the key at `POST https://api.sharkmagic.si/v1/token` ([api.md](api.md)) | | 402 | `credits_exhausted` | no (until you top up) | prepaid balance at zero; new work is refused, reads still work | top up | | 403 | `insufficient_scope` | no | the key lacks the scope (`details.required`, e.g. `network:write`) | use a key with that scope | | 403 | `insufficient_role` | no | account service: your role in the org cannot do it (owner/admin-only routes: API keys, top-up and plan, the Stripe portal, GitHub connect, invitations; a console token with a write scope for a role the console does not know). A team `member` gets every node scope, `secrets:write` and `network:write` included | ask an owner or admin | | 403 | `user_credential_required` | no | account service: a route about a person (`/v1/orgs` create and `use`, `/v1/logins`) called with an API key, which belongs to one org | `shark login`, or create a key in the other org | | 403 | `personal_workspace` | no | account service: inviting or adding someone to a personal workspace, or deleting it | create a team (`shark org create`) and invite people there | | 403 | `origin_not_allowed` | no | a browser preflight from an origin the node does not serve CORS to (the node's `-cors-origins`; on the hosted node `SHARK_CORS_ORIGINS`, default the console's origin) | call from the dashboard, or with a key outside a browser | | 403 | `plan_restriction` | no | allowed by the API, not for this account (e.g. egress `mode: open`, public previews) | | | 403 | `tenant_not_allowed` | no | the body names another account | drop `tenant` from the body: it comes from the key | | 403 | `tenant_suspended` | no | the account is suspended | contact support | | 404 | `not_found` | no | unknown id or name, deleted, **or another account's** (never 403, so ids cannot be probed) | | | 409 | `no_billing_customer` | no | account service: the Stripe portal before the account's first payment | top up or subscribe first | | 409 | `idempotency_mismatch` | no | an `op_id` or `Idempotency-Key` reused with a different request (`details.op_id`, `details.created_at`) | use a new id for a new request | | 409 | `idempotency_in_progress` | yes | the original request with that key is still running | retry after `Retry-After` | | 409 | `conflict` | no | the state does not allow it (hibernate while something runs: `details.why`; a duplicate name, a file write that raced a command on the same path: `details.paths`) | | | 410 | `op_expired` | no | the op's stored output passed its retention (7 days); its commit stays in `audit` | | | 412 | `precondition_failed` | no | `If-Match` on a file write does not match the current version | re-read, then write | | 413 | `payload_too_large` | no | body, `stdin` or upload above its limit ([limits.md](limits.md)) | stream the file or use the archive upload | | 416 | `invalid_request` | no | a `Range` the file cannot satisfy | | | 422 | `unsupported` | no | a valid request for something this node does not serve yet (`details.phase`); e.g. services API, snapshots, webhooks, exposed ports on a node without them | see the feature's page | | 426 | `upgrade_required` | no | a terminal route without the WebSocket upgrade | | | 429 | `rate_limited` | yes | request rate per credential (100/s, burst 500) | wait `Retry-After` | | 429 | `lease_cap_reached` | yes | your account's concurrent commands are at the cap (`details.scope` = `tenant`) or the node's (`host`). **The command did not start.** | wait `Retry-After`, resend with the **same** `op_id` | | 429 | `space_limit_reached` | yes | the node's live-Space cap (hibernated Spaces do not count) | retry, or hibernate idle Spaces | | 429 | `quota_exceeded` | no | an account quota (`details.quota` = `spaces`: Spaces held; `awake`: Spaces awake at once, on create, fork or wake; an exposed process's start or wake past the account's `awake_services` cap is refused with a `quota_exceeded` message the preview shows; `label_spaces` / `label_awake`: a per-label limit, with `details.label` and `selector`; with `limit`, `used`, `limits_class` and `raise`: how to get more) | `spaces`/`label_spaces`: delete Spaces; `awake`/`label_awake`: hibernate one (`shark hibernate`); your caps: `shark quotas`; more: `details.raise` | | 429 | `auth_rate_limited` | yes | too many failed authentications from your address | fix the key, then wait | | 429 | `too_many_terminals` | yes | 8 terminals already open on the Space | close one | | 500 | `internal` | yes, for idempotent requests | a bug on our side; `request_id` identifies it | retry with the same `op_id` / `Idempotency-Key`; report the `request_id` | | 503 | `out_of_capacity` | yes | the node admits no new command, Space or wake right now (`details.reason` = `memory_headroom` \| `memory_pressure` \| `cpu_pressure` \| `cpu_runqueue`; CPU reasons add `cpu_psi_some_avg10`, `runq_per_cpu`). **Nothing was started.** | wait `Retry-After` (≥ 2 s for CPU), resend with the same `op_id` | | 503 | `capacity_unavailable` | yes | a VM or wake could not be placed now; or (`details.reason` = `lease_start`, `details.op_id`) a sync command the node admitted but was too busy to start. **That command did not run.** | wait `Retry-After`, resend with the **same** `op_id`: it runs then | | 503 | `canceled` | yes | you disconnected while queued | resend with the same `op_id` | | 503 | `durable_unavailable` | yes | `checkpoint` could not reach the durable store | retry | | 507 | `insufficient_storage` | `scope: tenant`: no; `scope: node`: yes | your account's Spaces exceed their disk quota on the node (`limit_mb`, `used_mb`), or the node is low on disk. Deleting files and Spaces still works | delete files or Spaces / wait | **Warnings** (in a `2xx` body, `warnings: [{secret, code, message}]`; the request succeeded): | `code` | when | what to do | |---|---|---| | `secret_exposed_to_code` | a secret set with `expose: exec`: any code in a command that lists it can read the value | prefer `inject` | | `scope_host_not_allowed` | an inject secret's host is not allowed by the Space's egress policy | `shark egress add HOST` | | `account_secret_not_found` | a Space references (`{"ref": "account"}`) an account secret that does not exist: commands get nothing under that name until it is set | `shark secrets set --account NAME …` | Secrets routes also answer `400 invalid_request` with `details.field` = `secrets.NAME.apply` (`apply` is `all` or `ref`, account secrets only) or `secrets.NAME.ref` (`ref` is `"account"`, a Space's secrets only, with no value), and `422 unsupported` on a node without the sealed store. **SDK-only codes** (raised by the SDKs, never sent by the API): `wait_timeout` (Python `op.wait(timeout=…)` ran out of time; the command keeps running), `command_failed`, `infra_error`, `connection`, `config` ([sdk-python.md](sdk-python.md#errors)). ## Lines Shark adds to a command's output A command that ran (or was refused before it started) may end with one `shark:` line on stderr from Shark itself. They are meant for coding agents as much as for people: one line, what happened, the next step. | line | when | next step | |---|---|---| | `shark: egress: connection to HOST:PORT blocked by this Space's egress policy (…); … ask the user to run …` (the line ends with the exact `shark egress add` command) | the command connected to a host its Space does not allow (no-NIC egress; with the classic network the program only sees a refused connection) | `shark egress add SPACE HOST` | | `shark: background process "CMD" (pid N) was stopped when this command finished; nothing outlives a command here…` | a process started with `&` was still running at the end | use it within the command, or an exposed process / `shark run --detach` | | `shark: N background process(es) of this command keep running as job bg-N (CMD); listening on 127.0.0.1:PORT…; output: FILE; ps/pkill do not see it: …` | a node that keeps background jobs (hosted `shark code`): the process runs on in its own VM | `shark ps`, `shark logs bg-N`, `shark kill bg-N` when done | | `shark: capacity: … (the host is busy; the command did not run; retry…)` | the host could not start the command's VM | run it again after a few seconds | | `shark: quota: …` | the Space's CPU, memory or process limits | lower parallelism | | `shark: browser: not found in this VM. …` | a command naming a browser exited 127 (image without a browser) | install the browser in the Space; use it within one command | | `shark: browser: that name is not installed; this image's headless browser is …` | a command named another browser; the image has chrome-headless-shell | `$CHROME_PATH`, or `shark screenshot URL out.png` | Inside `shark code`, commands also get `$SHARK_MODE` (`devbox`), `$SHARK_SPACE` and `$SHARK_CONTEXT` (JSON: what works in this session); `shark context --json` prints it. See [shark-code.md](shark-code.md#what-the-agent-knows). ## Retrying safely The rule: **a retried request never does the work twice, and never silently does another request's work.** 1. Give every command an `op_id` and every create/fork an `Idempotency-Key`. Reuse it only for the *same* request. 2. Retry on connection errors, on `5xx`, and on any error with `"retryable": true`, after `Retry-After` when present (otherwise exponential backoff with jitter, from 0.5 s, capped at 30 s). 3. Do not retry `4xx` with `"retryable": false`; fix the request. 4. A retried `DELETE` that answers `404` means your first attempt worked. The CLI and both SDKs already do all four. The SDK error classes map one-to-one to the codes (Python: `RateLimitError`, `LeaseCapError`, `QuotaExceededError`, `CreditsExhaustedError`, `IdempotencyMismatchError`, `NotFoundError`, `UnsupportedError`, …; each has `.code`, `.status`, `.retryable`, `.retry_after`, `.request_id`, `.details`). ## Streams On an SSE stream (`exec` with `Accept: text/event-stream`, `/ops/{op_id}/events`), the last event is always `exit` or `error`; an `error` event carries `{"code", "message"}` with the codes above. A stream that drops before either is not an error: reconnect to `/ops/{op_id}/events` with `Last-Event-ID` and you get the rest, each event once. --- # Limits What the hosted node enforces at launch. **Your account's live values are authoritative**: `GET /v1/quotas` (your quotas with current use) and `GET /v1/capabilities` (the node's limits and features, no auth). Do not hard-code the numbers below in an agent; read them. ```sh curl -sS https://hel1.sharkmagic.si/v1/capabilities shark quotas --json # or GET /v1/quotas on the node with a node token (api.md, "Auth") ``` Every limit below answers with a stable error code when you hit it ([errors.md](errors.md)); none is a silent cap. ## Your account The account service sets per-account caps; the node enforces them and `GET /v1/quotas` shows each as a row with `limit` and `used` (`scope: tenant`): `spaces` (held, awake or hibernated), `awake_spaces`, `concurrent_leases`. The same answer names your account's class (`limits_class`) and how to raise a limit (`raise`). Defaults at launch, by class (an account can be given others; your rows are the truth): | cap | new account (first 7 days) | running on promotional credit only | standard | partner | when exceeded | |---|---|---|---|---|---| | Spaces held | 20 | 10 | 50 | 200 | `429 quota_exceeded`, `details.quota: spaces`: delete one | | Spaces awake at once | 7 | 7 | 7 | 100 | `429 quota_exceeded`, `details.quota: awake` on create, fork or wake: hibernate one (`shark hibernate`, or `shark space set --idle 30m` so idle ones sleep by themselves) | | commands running at once | 4 | 2 | 6 | 12 | queued, then `429 lease_cap_reached` (retryable, the command did not start) | | exposed processes awake at once | 2 | 1 | 3 | 20 | refused with `quota_exceeded`: stop or sleep one | | CPU per command | 4 CPUs | 2 CPUs | the VM's 8 vCPU | the VM's 8 vCPU | none: the command runs slower | A Space does not hibernate by itself unless it, or the node, has an idle setting: hibernate the ones you are not using. Every `429 quota_exceeded` names the cap (`details.quota`, `limit`, `used`), your `details.limits_class` and `details.raise`: how to get more (write to support@sharkmagic.si; design partners and SDK platforms get the `partner` limits). Prices are the same in every class. ```sh shark quotas --json # {"limits_class": "partner", "raise": "...", "data": [{"name": "awake_spaces", "scope": "tenant", "limit": 100, "used": 12}, ...]} curl -sS -H "Authorization: Bearer $SHARK_API_KEY" https://api.sharkmagic.si/v1/limits # class, caps, label caps (no use) ``` ### Partner limits For design partners and SDK platforms whose own end users each get Spaces: the `partner` class, set by Shark (ask us; an operator turns it on with one command and it reaches the nodes within seconds). Its numbers are sized to what one node serves: 200 Spaces is the node's per-account ceiling, 100 awake is a quarter of the node's 400 live Spaces. The node's own protections still apply on top of any class: 50 GiB of disk per account, and its admission (`503 out_of_capacity` while memory or CPU is short, retryable). ### Per-label limits A platform can cap each of its own end users by a label of their Spaces (Spaces take `labels` at create, fork and `PATCH`). A selector `key` caps **each value** of that label on its own; `key=value` caps that one value and wins over `key` for it. Two counts: `awake` (Spaces awake at once) and `spaces` (held). A Space without the label is not capped by it. They only tighten: the account's caps above still apply. ```sh shark quotas labels set end_user --awake 3 --spaces 20 # every end user: 3 awake, 20 held shark quotas labels set end_user=acme --awake 10 # acme gets 10 awake shark quotas labels --json # {"class": ..., "label_caps": {"end_user": {"awake": 3, "spaces": 20}, ...}} shark quotas labels rm end_user=acme # HTTP (account service): PATCH /v1/limits/labels {"labels": {"end_user": {"awake": 3}}} (null removes a selector) # PUT /v1/limits/labels {"labels": {...}} (replaces all; null clears) ``` | limit | value | when exceeded | |---|---|---| | selectors per account | 32; key `[A-Za-z0-9._/-]{1,63}`, value 1–255 chars | `400 invalid_request`, `details.field` | | a value's Spaces awake / held | what you set (integer 0–1,000,000; 0 = none allowed) | `429 quota_exceeded`, `details.quota: label_awake` or `label_spaces`, `details.label` (`end_user=alice`), `details.selector`, `limit`, `used`; on create, fork, wake and on a `PATCH` that moves a Space into a full value | `GET /v1/quotas` shows one row per selector and count (`scope: label`): for `key=value` its use; for `key` the fullest value (`label`, `used`) and how many values you use (`values`). A change reaches the nodes within one poll of the account service (about 2 s). ## Commands | limit | value | when exceeded | |---|---|---| | lease shape (each command's microVM) | 8 vCPU, 4 GiB | — | | `timeout_s`, sync `exec` (JSON when it ends) | default 120 s, **max 900 s** | `400 invalid_request`, `details.max_s: 900` | | `timeout_s`, streaming (`Accept: text/event-stream`) or async (`"wait": false`) | default 30 min, **max 7,200 s (2 h)** | `400 invalid_request`, `details.max_s: 7200` | | output returned by a sync `exec` | last 1 MiB of each stream (`stdout_truncated: true`) | the full output stays in the op log | | op log (stored output per op) | 32 MiB per stream, then `truncated` | | | op retention (result and output) | 7 days | `410 op_expired`; the commit stays in `audit` | | `cmd` | 1 B – 256 KiB | `400` | | `stdin` | 1 MiB | `413 payload_too_large` | | `env` per request | 128 names, 16 KiB per value | `400` | | `op_id` | 1–64 of `[A-Za-z0-9_.:-]` | `400 invalid_op_id` | | commands running at once, per account | your account's cap ([above](#your-account); then a queue; too long a wait gives `429 lease_cap_reached` before the command starts) | `429`, retryable | | node admission (memory and CPU) | the node refuses new commands, Spaces and wakes while its memory is short or its CPUs are saturated; live state: the `node_admission` row of `GET /v1/quotas` | `503 out_of_capacity` with `Retry-After` (`details.reason`), retryable; nothing started | | `stdin` streaming into a running command (`stdin_stream`) | not served yet | `422 unsupported` | ## Spaces and files | limit | value | when exceeded | |---|---|---| | Spaces per account (live and hibernated) | your account's cap ([above](#your-account)) | `429 quota_exceeded` | | disk per account on the node | 50 GiB (51,200 MiB) | `507 insufficient_storage`, `scope: tenant` | | live Spaces per node (hibernated ones do not count) | 400 | `429 space_limit_reached`, retryable | | forks per call | 1–50 | `400` | | labels per Space | 32; key `[A-Za-z0-9._/-]{1,63}`, value ≤ 255 chars | `400` | | name | `[A-Za-z0-9._-]{1,63}`, unique per account | `400` / `409 conflict` | | JSON request body | 1 MiB | `413` | | single file upload (`PUT /v1/spaces/{id}/files`) | 512 MiB, streamed | `413` | | archive upload (`POST /v1/spaces/{id}/files/archive`) | 2 GiB | `413` | | `Idempotency-Key` | 1–255 chars, kept 24 h | | ## Secrets, network, terminals | limit | value | |---|---| | secrets per Space | 64 | | secret value size | 16 KiB | | egress `allow` entries | 256 | | terminals per Space (`shark connect`) | 8 (`429 too_many_terminals`) | | one-time preview login link | 60 s, one use | | exposed process idle before it sleeps | `idle_s`, default 30 s | ## API | limit | value | |---|---| | request rate per credential | 100/s, burst 500 (`429 rate_limited` with `Retry-After`) | | failed authentications per address | limited (`429 auth_rate_limited`) | | transport | HTTPS only (plain HTTP is refused except on loopback) | ## Not available (today) GPUs; non-Linux guests; process memory carried across commands; the services, snapshots, restore, connectors, model-gateway, events and webhooks routes (`422 unsupported`); `stdin` streaming; E2B SDK compatibility; a published SLA or uptime record. `shark connect` is **coming** on the hosted node (exposed processes and preview URLs are live: [concepts.md](concepts.md#exposed-processes-and-preview-urls)). Values: `cmd/sharkfs` (`api_v1*.go`, `node*.go`), `internal/apiv1`, `internal/secrets`, and the hosted node's configuration (`deploy/v1/shark-api.service`) as of 2026-11-05; the account defaults from the account service (`control-plane/src/config.ts`, `DEFAULT_CAPS`) as of 2026-11-07. Account quotas can differ per account; `GET /v1/quotas` tells you yours. --- # Framework integrations (Python): LangChain, LangGraph, smolagents, Prefect Run agent-written code and heavy jobs in Shark from the framework you already use. Each integration is a small module of the Python SDK (`shark.integrations.*`) installed as an optional extra; `import shark` never needs any framework. **Status.** The SDK and these integrations use API v1 (`/v1/...`: files, fork, op replay). They were tested against a local `sharkfs api` whose commands run with bash on the host instead of a microVM (no isolation, no network policy, no VM timings), with the framework versions listed in [Tested against](#tested-against). They have not yet run against a hosted endpoint, and API v1 is not yet confirmed on the measured bare-metal server. TypeScript adapters do not exist yet. ## Install ```sh pip install 'shark-sdk[langgraph]' # or [langchain], [smolagents], [prefect]; several: [langgraph,prefect] export SHARK_API_KEY=... # an account key; never put it in code or in the agent's prompt. Leave SHARK_API unset (direct mode) ``` `shark-sdk` is a provisional name and the package is not on PyPI yet: install it as your invite says ([quickstart.md](quickstart.md#0-what-you-need)), with the extra for your framework. ## Which piece to use | you have | use | the Space comes from | |---|---|---| | a LangChain agent or any tool-calling loop | `SharkExecTool` (+ `SharkReadFileTool`, `SharkWriteFileTool`) | `space=`, or per run from the config | | a LangGraph graph with a checkpointer | `thread_space`, `branch_spaces`, and the tools above with `shark=` | one per `thread_id`; a fork per parallel branch | | a smolagents `CodeAgent` | `SharkExecutor` (`executor=`) or `SharkCodeAgent(executor_type="shark")` | `space=`, or a new one per executor | | a Prefect flow | `task_fork`, `task_op_id` | a fork of a base Space per task run | ## LangChain: one tool, bash or Python ```python from shark import Shark from shark.integrations.langchain import SharkExecTool shark = Shark() tool = SharkExecTool(space=shark.spaces.create(labels={"client": "a"})) print(tool.invoke({"command": "mkdir -p data && echo hello > data/a.txt && ls data"})) print(tool.invoke({"command": "print(open('data/a.txt').read().upper())", "language": "python"})) # exit: 0 # stdout: # HELLO ``` What the model sees is one tool, `shark_exec`, with `command`, `language` (`bash` or `python`) and `timeout_s`. It gets back the exit code, stdout and stderr (their last 20,000 and 10,000 characters by default: `max_output_chars`). A command that fails is a result (`exit: 1`), not an exception; a timeout comes back as `exit: timeout`; an infrastructure error is raised as a `ToolException`, which LangChain hands to the model as text. - **Exactly once.** When the tool is called with a model tool call (LangGraph's `ToolNode`, or `tool.invoke(tool_call)`), the command's `op_id` is derived from the tool-call id. If a graph resumes from a checkpoint and executes the same tool call again, Shark returns the stored result (`replayed: true`) instead of running the command a second time. - **Files without a VM.** `SharkReadFileTool` and `SharkWriteFileTool` read and write the Space's files directly; no command runs. `shark_tools(space)` returns the three tools together. - **Your code, not the model's.** `tool.upload("./local_dir", "in")` and `tool.download("out", "./results")` move whole directories (one commit each). - Each call runs in a fresh machine: files persist to the next call; `cd`, exported variables and running processes do not. Use `cwd=` and `env=` on the tool for defaults. ## LangGraph: one Space per thread, one fork per branch ```python from langgraph.types import Send from shark.integrations.langchain import shark_tools from shark.integrations.langgraph import thread_space, branch_spaces tools = shark_tools(shark=shark) # resolves the thread's Space at call time: use them in a ToolNode def prepare(state, config): # a node thread_space(shark, config).files.write("data/leads.csv", state["csv"]) return {} def fan_out(state, config): # a conditional edge: N forks, N branches in parallel forks = branch_spaces(shark, config, len(state["strategies"]), step="dedupe") return [Send("branch", {"space_id": f.id, "strategy": s}) for f, s in zip(forks, state["strategies"])] ``` - `thread_space(shark, config)` is the Space of `configurable.thread_id`. It is created on first use and found again by label on every later run of that thread, from any worker; two workers racing on a new thread get one Space. For one Space per client instead of per thread, call `space_for(shark, "client:")` from `shark.integrations`. - `branch_spaces(...)` forks that Space: O(1), the files are shared and nothing is copied. Processes and service memory are not forked, and a fork starts with no op history. The fork request is idempotent per graph task, so a node re-executed after a resume gets the same forks; a new run of the thread gets new ones. - Inside a branch, run commands on the fork (`shark.spaces.get(space_id).run(...)`) or hand it to the tools with `config={"configurable": {"shark_space_id": space_id}}`. Keep the winner's files (read them from the fork and write them into the thread's Space), then delete the forks. - The tools pick the Space in this order: `space=` if given; `configurable.shark_space_id`; the thread's Space. A complete graph (prepare, fan out over three forks, pick the best, clean up) and a model → `ToolNode` loop are in `sdk/python/examples/langgraph_threads_and_forks.py`. ## smolagents: Shark as the CodeAgent's executor ```python from smolagents import CodeAgent from shark.integrations.smolagents import SharkExecutor, SharkCodeAgent space = shark.spaces.create(labels={"agent": "leads"}) agent = CodeAgent(tools=[], model=model, additional_authorized_imports=["requests"], executor=SharkExecutor(["requests"], space=space)) agent.run("...") # executor_type-style, same thing: agent = SharkCodeAgent(tools=[], model=model, executor_type="shark", executor_kwargs={"space": space}) ``` `SharkExecutor` implements smolagents' `RemotePythonExecutor` interface, the one its E2B, Docker, Modal and Blaxel executors implement (checked against smolagents 1.26.0). smolagents' `executor_type` only accepts its own list of names, so a Shark executor goes in through `executor=`, or through `SharkCodeAgent`, a `CodeAgent` subclass that also accepts `executor_type="shark"`. **State works differently from a notebook kernel.** Each code cell runs in a fresh microVM, so nothing stays in memory between cells. The executor keeps Python state in the Space's files (`.shark-smolagents/`): the tools, and each cell's top-level imports, functions and classes, are replayed before the next cell; other variables are pickled after each cell. A value that cannot be pickled (an open file or socket, a generator, a lambda, a live browser) is dropped, and the cell's logs name it so the model can recreate it. Files the code writes persist as usual. - Packages: modules in `additional_imports` that the Space cannot import yet are installed once with `pip --target` into the Space and reused by every later cell and every fork. `packages=[...]` gives exact pip requirements; `pip_args=[...]` adds options such as a private index. - Each cell is one command: a lease costs 44 ms on the hosted hardware ([numbers.md](numbers.md), AX41, 2026-11-05), plus the replay of the cell's state. - `timeout=` is per cell (default 300 s); a cell that runs out of time comes back to the agent as an error it can react to. `cleanup()` deletes the Space only if the executor created it. - Not supported: smolagents managed agents with a remote executor (smolagents' own restriction). ## Prefect: burst instead of queue Prefect stays the control plane (schedules, retries, the UI); the work runs in Shark. Prepare a base Space once (code, dependencies), then give every task run its own fork, so N heavy jobs run side by side instead of waiting in a queue for one large worker. ```python from prefect import flow, task, unmapped from shark.integrations.prefect import task_fork, task_op_id @task(retries=2) def process(base_id: str, url: str) -> str: with task_fork(shark.spaces.get(base_id)) as sp: # this task run's own fork, deleted afterwards r = sp.start(f"python3 job.py '{url}'", timeout=3600, op_id=task_op_id("job")).wait() assert r.exit == 0, r.stderr[-2000:] return sp.files.read_text("out/summary.json") @flow def burst(base_id: str, urls: list[str]): return process.map(unmapped(base_id), urls).result() ``` The fork's idempotency key is the task run id plus its attempt number: a request the SDK retries returns the same fork, and a Prefect retry starts from a fresh fork of the base. No Prefect server is needed to run this (Prefect's ephemeral local mode works); with `PREFECT_API_URL` set the runs show up in your server or Prefect Cloud. Full flow: `sdk/python/examples/prefect_burst.py`. ## Heavy jobs: one command does the whole job A job like "download a 500 MB archive, unzip it, parse every document" belongs in **one** command (one script), not spread over several tool calls: - Download and unzip into the command's temporary directory (`$TMPDIR`, or Python's `tempfile`), not into the Space. Write only what you need afterwards (a summary, the extracted fields) into the Space: everything a command leaves in the Space's tree is saved when it ends. - Set `timeout` to the whole job, and mind the server's caps: a synchronous `space.run()` accepts up to 15 min by default, an async op (`space.start(...)` then `.wait()`, as in the Prefect example) up to 2 h. A larger `timeout` is refused with `400 invalid_request` (its details give `max_s`), so use an async op for longer jobs. The tools and the smolagents executor use synchronous calls. - A headless browser works if it starts and exits inside the command: install it into the Space once (for example Playwright with its browser in a directory of the Space), then each command launches it, does its steps and closes it. The browser does not stay open between commands. - In parallel: fork once per job (Prefect above, or `shark.integrations.forked(space, key)` anywhere). Not measured yet: a 500 MB download, unzip and parse on a Shark host, alone or many in parallel, and a headless browser inside a command. The local tests ran this pattern with small archives (≈ 1 MB each) on the stand-in described in **Status**. ## Exactly once and retries | integration | op_id / idempotency key | what a retry does | |---|---|---| | `SharkExecTool` called with a tool call | `lc:` | returns the stored result, `replayed: true` | | `SharkExecTool` called with plain input | generated per call | the SDK's own retries reuse it; a new call runs again | | `thread_space` / `space_for` | create: `space:` | the same Space (the label finds it after the key's 24 h) | | `branch_spaces` | fork: thread + graph task + step | the same forks while the key lives (24 h) | | `task_fork`, `task_op_id` | task run id + attempt | SDK retries: same fork and op; Prefect retry: new fork | | `SharkExecutor` cells | generated per cell | the SDK's own retries reuse it | A reused op_id with a different command is refused (`409`), never silently replayed. op_ids are per Space. ## Not supported yet - **GPUs.** - **Long-lived processes across commands**: a dev server, a watcher, a Jupyter kernel. Each command's processes end with it. (A node may keep a command's background process running as a job of the Space: hosted `shark code` does, see [concepts.md](concepts.md#background-processes); the API/SDK integrations here do not rely on it.) - **Persistent browser sessions**: a browser kept open between steps, with its tabs and logins. A browser that runs inside one command works; one that lives across commands is a design, not a product. - **Preview URLs from the SDK tools**: live on the hosted node through the API and the CLI (`shark expose`, `shark open`), but the SDKs and these integrations have no method for them yet ([concepts.md](concepts.md#exposed-processes-and-preview-urls)). - Streaming a command's output to the model as it runs (the tools return when the command ends; the SDK's `space.stream()` streams to your own code). - TypeScript adapters (LangChain.js and others); smolagents managed agents with remote execution; Windows and macOS guests. ## Tested against `sdk/python/tests/test_integrations.py`, 16 tests, all passing on 2026-11-05 with Python 3.13, langchain-core 1.7.6, langgraph 1.2.20, smolagents 1.26.0, prefect 3.9.1 (pinned in `sdk/python/tests/integrations-constraints.txt`), against a local `sharkfs api` with the stand-in for the microVM described in **Status**. Model turns are scripted: no model was called. On that stand-in a command's time limit is enforced by the API about 30 s after `timeout_s`, not by the microVM, so the timeout tests only check that a timeout reaches the model as a result. What the tests do not cover: a hosted endpoint, real microVMs, network policy, large downloads, a real model. --- # Security What Shark does today and how to report a vulnerability. **Short version:** every command runs in its own Firecracker microVM with its own Linux kernel; third-party API tokens stay outside the VM by default; each Space has an outbound network policy. ## What we do ### Compute isolation - **A microVM per command.** Each command gets a fresh Firecracker microVM with its own kernel, restored from a snapshot and destroyed when the command ends. No process, memory or kernel state survives into the next command; only the Space's committed files do. Services (Postgres, Redis) and kept jobs run in VMs of their own. - **Every API command is in a VM.** The node runs strict mode: an API command never runs on the host. - **Per-account snapshot templates.** The memory template a VM is restored from is a separate physical copy per account, so two accounts never share template pages (this closes a cache side channel between accounts). Memory deduplication across accounts (KSM) is off. Verified locally (P108 L1: 0 shared physical pages across accounts). - **Node protection.** The node agent and system run in a reserved cgroup slice; all accounts' VMs run in a capped slice, with per-account quotas (Spaces, disk, concurrent commands) and admission control that refuses new work with `503 out_of_capacity` instead of overcommitting memory. ### Secrets - **Never in the VM by default.** A secret stored with `expose: inject` (the default) reaches a command only as a placeholder (`shk_ph_…`, different per command). The node's egress proxy, outside the VM, swaps in the real value only in requests to the hosts you scoped it to, and scrubs it from responses. An agent that can run code in the Space can use the token against its intended API, but cannot print or send the value elsewhere. - `expose: exec` is an explicit, per-secret opt-in that puts the value in the environment of the commands that ask for it; the API warns `secret_exposed_to_code` when you choose it. - Values are sealed per Space at rest (AES-256-GCM), never returned by any API, never written to the Space's files, never replicated, and redacted from logs. Forks do not copy secrets. - Verified locally (P111 N1–N4, 2026-11-05): 0 occurrences of a secret value in the guest's environment, memory, files, outputs, logs or registration messages over 696 authenticated requests; fail-closed in 6/6 broken setups. ### Network - **Per-Space egress policy by API**: `allowlist` (the hosted default, with a base list of package registries and GitHub), `deny` or, where allowed, `open`. Web traffic is matched by name; bare IPs need an explicit public `cidr:port`; **private and link-local ranges (including cloud metadata addresses) are always denied**, and so are the node's own addresses except on the ports it serves to the internet (443 and 80), even when a policy entry names them. - In the hosted configuration a VM has no network card: its TCP and DNS reach only the node's egress proxy, which enforces the policy and performs secret injection. - Verified locally (P111 E1/E2): 24/24 policy probes as expected, private destinations denied in every mode. ### Accounts, keys, API - The account comes from the credential, never from the request body. Another account's Space answers `404`, never `403`, so ids cannot be probed; Space ids carry 128 random bits. - API keys are shown once and stored only as an HMAC under a server-side pepper; compared in constant time; scoped (`exec`, `files:write`, `secrets:write`, …). Failed authentications are rate-limited per address. - HTTPS only; the node refuses plain HTTP except on loopback. Behind its TLS proxy, only that proxy may set the client address. - **Preview URLs** (live on the hosted node) are private by default: a one-time, 60-second login link sets a `__Host-` cookie (Secure, HttpOnly, SameSite=Lax) for that one preview host; the cookie never reaches your server process. Local gates: 30/30 unauthorized attempts refused with 0 requests reaching the server (P112 H2.1). On the hosted node (P130 flow, 2026-11-07): requests without the cookie, a reused link and an expired link were all refused (`401`), turning public off refused again, and removing the port revoked it (`404`). ### Where the agent harness runs Only the commands an agent runs go to microVMs. In the hosted products the harness itself (the agent program and its file tools) runs in the Space's session VM (`shark code`) or on your own machine (the SDK), never on a shared node. Our density tests also use a third mode, **self-hosted only**: the harness on the host with its shell routed through Shark's shim. In that mode only the shell goes to a VM; the harness's own file tools (read, write, edit) act on the host with whatever access the harness's user has. We found this in our own test run on 2026-11-05 (P113 r2-8: agents running as root read files outside their project), so the test now runs the harness as a dedicated unprivileged user that cannot read anything outside its Space, checked before each run. If you run a harness this way on your own machine, give it the same kind of user; it is not a mode we offer. ### Privacy of usage data Usage records hold counts and durations only, never command text, paths or file contents. Operators can turn off the logging of command text per account on the host. ## Reporting a vulnerability Write to ``. Please include steps to reproduce and the `request_id` of any API response involved. Test only against Spaces of your own account. ## Your side - Keep API keys in the environment or `~/.shark/credentials` (0600); never in prompts, repos or the Space. - Give agents keys with only the scopes they need; keep `secrets:write` and `network:write` for yourself. - Store third-party tokens as `inject` secrets with a narrow `scope.hosts`; use `exec` only for values code must read. - Start Spaces with `egress` set to the narrowest allowlist that works. --- # Measured numbers, with their fine print Every number here comes from a pre-registered proof with written PASS / FAIL thresholds; failures are listed next to wins. Two machines appear on this page; always read which one a number is from: - **AX41**: the hosted node `hel1` (Hetzner AX41: AMD Ryzen 5 3600, 6 cores / 12 threads, 64 GB RAM, 2 × 512 GB NVMe, the hosted node). Its numbers are the ones that describe the service you use. - **EPYC**: a larger lab server (AMD EPYC 9254, 24 cores / 48 threads, 751 GiB RAM, 2 × 1 TB NVMe) used for density and scaling proofs on 2026-11-02. Its numbers do not transfer to the AX41 one-to-one. Unless noted, latencies are server-side (the client on the same machine, no internet round trip) and the lease shape is 8 vCPU / 4 GiB. ## On the hosted hardware (AX41, metal run of 2026-11-05) | what | result | |---|---| | lease: a fresh microVM for one command | **44 ms** | | create a Space → first command done | **65 ms** | | burst: 24 new Spaces, each created and running its first command, at once | all 24 done in **342 ms** | | first command of a sleeping (hibernated) Space | **58 ms** | | a sleeping service answers its first query (Postgres, the example the measurement used) | **37–40 ms** | | create a durable Space (replicated to the object store), server time | **1.3 ms** | | share of a command's time that is the user's own command (the rest is Shark's overhead) | **91–96 %** | | Grep/Glob served from the index (`sharkfn`) vs ripgrep over the FUSE mount, 100k-file tree | **300–600× faster** | These come from the AX41 metal report of 2026-11-05. Exposed processes and previews were run end to end on the hosted node on 2026-11-07 (below). ## On the lab server (EPYC, 2026-11-02) "Default" = the server's default flags; "BEST" = an opt-in set of flags that was not the default then. ### Latency | what | result | config | proof | |---|---|---|---| | create a Space → first command (`true`) done, sequential | p50 **55.3 ms**, p99 59.0 (n=100) | BEST | P85 r3 H3 | | same | p50 86.8 ms, p99 89.7 (n=100) | default | P85 r3 H3 | | n-th command (`true`) in an existing Space | p50 46.7 ms, p99 54.2 (n=400) | BEST | P85 r3 H1 | | same | p50 85.9 ms, p99 93.4 (n=400) | default | P85 r3 H1 | | burst: 100 new Spaces at once, create + first command each | per request p50 **408.4 ms**, p99 514.0; all 100 done in 516–524 ms; 2 bursts, 200/200 succeeded | BEST | P85 r3 H4 | | same | per request p50 803.9 ms, p99 886.9 | default | P85 r3 H4 | | Postgres 17 from **pause**: `psql -c 'select 1'` from inside a lease | p50 **31.5 ms**, p99 40.2 (n=99) | default | P85 r3 H5b | | Postgres 17 from **snapshot** | p50 48.2 ms, p99 69.5 (default); p50 37.3, p99 86.5 (BEST) | | P85 r3 H5a | | first command of an idle Space | p50 59.5 ms, p99 62.1 (BEST, hibernated) vs 94.3 / 105.1 (default) | | P85 r3 H7b | Fine print: P85's pre-registered global verdict for BEST is **FAIL**: it won every latency hypothesis above but lost H8 (idle CPU per Space was already ~0 with defaults, so BEST could not lower it) and H6 (memory per live VM; the analysis was broken and is read as inconclusive). Sources: `proofs/p85-best-setup/results/ks5-phase-cfbf65a52-sprint-r3/summary.md` and `READING.md`; per-phase breakdown in `proofs/p96-bottlenecks/forensics-lifecycle/REPORT.md`. ### Dormant Spaces and scale | what | result | proof | |---|---|---| | disk per dormant Space | **57,037 bytes** (sampled `du` at the highest step; target ≤ 256 KB: PASS) | P87 D3 | | dormant Spaces held | **53,312** at the end of the run (target ≥ 100,000: **FAIL**, limited by run time and creation rate) | P87 D1 | | wake a dormant Space (first command) with 53,312 dormant, server quiet | p50 **62.5 ms**, p99 67.1 (n=50) | P87 b4, P96 | | same, while ~196 Spaces/s were being created | p50 245–268 ms, p99 up to 619 ms | P96 table D | | Spaces created on one server in one run | **150,956** at ~195/s; **143 failed** (0.095 %), cause not recorded | P87 b4, P96 C2 | | wrong result on wake | 1 of 750 wakes did not return its token; plausibly a harness sampling bug, **not proven** | P96 C1 | P87 D2 (wake latency) is INCONCLUSIVE because of that unexplained token. A projection of ~14.8 million dormant Spaces by disk size is a **projection**, not a measurement. Sources: `proofs/p87-real-density/results/ks5-phase-c68463b52-r1/summary.md`, `proofs/p96-bottlenecks/forensics-lifecycle/REPORT.md`. ### Live VMs | what | result | proof | |---|---|---| | 1,000 live microVMs at once, each serving short commands and probes | **0 failures**; but short-command p99 46.3 ms vs 20.7 ms at 100 VMs: outside the pre-registered latency SLO | P87 b1 | | most live VMs inside the SLO | 500 (target ≥ 4,000: **KILL**) | P87 V1 | | private memory per live VM (at 500) | 18.9 MiB (target ≤ 64 MiB: PASS) | P87 V2 | | idle VMs started (no command), BEST | 4,000 of 4,000 (defaults: 917 of 1,000) [exploratory] | P85 r3 READING | ### Grep and Glob (`shark-rg`), local sandbox only Measured in a 4-core development VM **without KVM**, not on metal (P95). Claude Code's own Grep argv, Go source tree: | | shark-rg p50 | `rg` over the FUSE mount p50 | `rg` on local disk p50 | |---|---|---|---| | Grep (n=300) | **18.0 ms** | 437.9 ms | 36.7 ms | | Glob (n=100) | 6.8 ms | 249.6 ms | 16.6 ms | 100,000-file tree: Grep 17.4 ms vs 392.7 ms for `rg` on local disk; Glob 74.6 ms (target ≤ 50 ms: missed). P95 verdicts: T1 PASS (tool definitions sent to the model byte-identical), T2 KILL for 5 flag combinations (they now go to the real `rg` by default; 0 differences in 4,320 calls after fixes), T3 FAIL, T4 PASS (89 % of calls served), T5 FAIL (CPU per call), T6 PASS (meter privacy). Source: `proofs/p95-sharkfn-transparent/README.md`. ## Agent time (public traces) 3,921 autonomous public sessions, 349.2 h: model 82.7 %, commands and services 10.2 %, idle 5.9 % (P88, `proofs/p88-tool-call-profile/landing-hour.md`; details in [billing.md](billing.md)). Derived from that profile and the measured per-hop costs (a model, not an end-to-end run): Shark adds ~37 s per autonomous agent-hour with defaults and ~21 s with BEST (P96, `proofs/p96-bottlenecks/forensics-agent-hour/REPORT.md`). ## Not ready - **Secrets, egress, per-account templates on metal**: passed locally (P111, P108), on-metal runs written and not run. - **`shark connect`** (the terminal over the API): local gates PASS (P112); not yet run on the hosted node. Exposed processes and previews are live there: the end-to-end flow passed on `hel1` (P130, 2026-11-07; a preview woken from sleep served its first byte in p50 127 ms, p99 153 ms over 30 wakes; results file `proofs/p130-sleeping-services/results/metal-r1/flow.json` on the `metal/ax41` branch). - **Failure records.** In the EPYC density runs, failures (143 creates, 1 wake) have no recorded cause. --- # Billing and credits Shark bills **what runs, per second**, from a **prepaid credit balance**. The model thinking, your agent waiting, and a Space sitting idle run no VM and bill no compute. Prices are listed below; your account and checkout always show the current ones. ## Plans One active plan at a time. Each plan includes usage at its own price; included usage is spent first, then prepaid credits. | plan | price | included usage | |---|---|---| | **Shark Pro** | $5/month | $5 | | **Shark Studio** | $25/month | $25 | | **Shark Scale** | $100/month | $100 | ## Usage rates | unit | rate | included | |---|---|---| | **Compute**, standard 8 vCPU / 4 GiB | $0.12 per compute-hour | none; billed per second only while a command runs | | **Service**, 2 vCPU / 2 GiB | $0.03 per hour | none; billed only while awake | | **Service**, 4 vCPU / 4 GiB | $0.06 per hour | none; billed only while awake | | **Service**, standard rate | $0.06 per hour | none; billed only while awake | | **Storage** | $0.08 per GiB-month | first 5 GiB free per Space per billing cycle, overage only | | **Egress** | $0.03 per GB | first 10 GiB free per Space per billing cycle, overage only | Commands run on the standard 8 vCPU / 4 GiB compute shape; there is no 2 vCPU / 2 GiB compute shape. ## Credits - You buy credits ahead (prepaid top-ups by card, `shark topup`) and usage draws them down. There is no per-start fee. - **Monthly plan.** One active plan at a time (`shark plan subscribe`, paid through Stripe Checkout). Each paid period grants its included usage; usage spends it before your paid credit, it expires at the end of that period (unused included usage does not roll over), and usage beyond it comes from prepaid credit. Cancelling (`shark plan cancel`) takes effect at the end of the paid period, and the included usage runs until then; subscribing again before that resumes it. A refunded plan payment takes back what is left of its included usage. `shark plan --json` (`GET /v1/plan` on the account service) shows whether the plan is offered, its status and period end, and the allowance left; where it is not offered yet the plan routes answer `503 plan_unavailable` and prepaid credit works as usual. The plan is managed with a person's login or key, never with a Space credential. The plans and their included usage are listed above; your account and checkout show the current price. - **Promotions**, when offered, are shown at checkout and follow their own terms. Like included plan usage, they are spent before paid credit. - **Alerts** at 50 %, 80 % and 100 % of the credit funded in the current cycle, once each. - **At zero balance** new work (commands, creates, forks, wakes) answers `402 credits_exhausted`, your Spaces are hibernated, and **files stay readable** (reads are served without a VM). A top-up lifts it. - Balance, top-ups and usage by day, Space or kind are in the account console and API at `api.sharkmagic.si` (**coming** at launch). Until then: `GET /v1/usage` on the node, and your invite says how credits are handled during early access. ## What is metered | unit | counted while | not counted | |---|---|---| | **command seconds** | a command runs in its microVM (`run_s`, measured inside the guest), scaled by lease shape relative to the standard shape, with a small per-command minimum | VM start and stop, queueing, the model thinking, you reading output; commands that ended in a Shark-side failure (`infra_error`, partial runs); replays of an `op_id` (0 compute) | | **service seconds** | any service you start is awake: a Space's Postgres or Redis, or an exposed process (`shark expose`, previews); scaled by the service's size (vCPU and memory) relative to the standard shape, **below 1 for a small service** (an exposed process's 2 vCPU / 2 GiB VM counts half a standard second) | while it sleeps (its snapshot is not compute; its files are in storage); VM boot before the port answers; wake-ups Shark causes itself (housekeeping timers) | | **process seconds** | a background job of a command is awake | while it sleeps | | free | the coding agent's own session VM under `shark code` (where the harness runs and decides); its commands are billed as command seconds | | | **storage** | the Space's deduplicated bytes, sampled hourly (GiB-hours) | bytes shared with other Spaces through deduplication are counted once | | **egress** | bytes sent and received by commands over the network | | | free | file reads and listings (served from the tree without a VM), API calls themselves | | Each record is rated exactly (rational arithmetic, floored per record: rounding is always in your favour) and debited once, however often it is re-sent (control-plane gates B3.a–c: 0 double credits and 0 overcharges in 1,000 randomized histories, 2026-11-05, local). `GET /v1/usage?group_by=day|space|kind` and `shark usage` show the metered quantities (counts and durations only, never command text, paths or file contents). ## Why per-second-of-work matters for agents Measured on public agent transcripts (P88; OpenHands SWE-bench Lite evaluation outputs and public Claude Code traces, all with open licenses). Over **3,921 autonomous sessions, 349.2 hours**: | where the time went | share | |---|---| | model thinking and generating (plus harness overhead between events) | **82.7 %** | | shell commands and services, billed per call with a 0.1 s floor | **10.2 %** (9.8 % without the floor) | | idle gaps over 10 minutes | **5.9 %** | | file ops, network and harness tools, other | 1.6 % | A sandbox billed for its whole lifetime bills all 349 hours; per-command billing bills about 36 of them. Your mix will differ: measure it. ## Measure your own idle share: `idle-tax` `idle-tax` is an open-source (Apache-2.0) CLI that reads **your own** Claude Code and Codex transcripts on your machine and prints only numbers: how much of each session was the model thinking, tools running, waiting for you, or idle. No network access, writes nothing, never prints prompts, commands, paths or file contents. ```sh node idle-tax.js # reads ~/.claude/projects and ~/.codex/sessions node idle-tax.js --path node idle-tax.js --json # machine-readable node idle-tax.js --share # anonymized block (percentages and hours only) ``` Get it: `` (one file, Node 18+, zero dependencies). Its cost table compares billing models at dated list prices it ships with; any "Shark" row in it is a modelling input, not a Shark price. --- # FAQ ## The model **What is a Space?** An agent's persistent workspace: its files, plus its own Postgres or Redis when it needs them. Nothing runs for it while it is idle. [concepts.md](concepts.md#space) **Is a Space a VM that stays up?** No. Each command gets a fresh microVM that exists only while the command runs. The files persist; process memory and shell state (`cd`, `export`) do not. If you need state across commands, write it to a file, use a service, or use `shark connect` (coming), which carries `cd` and `export` for you. **Why one VM per command instead of one long-lived VM?** Because agents spend most of their time thinking, not running commands (82.7 % model time vs 10.2 % commands and services over 3,921 public sessions, P88). A VM per command means nothing runs, and nothing bills compute, between commands. The cost is a VM start per command: measured at 44 ms on the hosted hardware ([numbers.md](numbers.md)). **Can I run a server that stays up?** Yes, as an exposed process (`shark expose`): it sleeps when idle and wakes on the next request, and gets a preview URL (private by default). **Live on the hosted node** (P130 flow PASS, 2026-11-07), through the CLI and the API; billed while awake, by size, and not while asleep. Background processes started by a command can also be kept as jobs where the node enables it (hosted `shark code` does: `shark ps`, `shark kill bg-N`). **Can I run Postgres?** Yes, as a per-Space service: commit `.shark/services.toml` with `[postgres]` and `version = "17"`; commands reach it at `127.0.0.1:5432`. It sleeps when unused and wakes on connect (37–40 ms measured). Beta: declaring it in an already-running Space is not yet measured end to end. Redis 7 works the same way. **How do I get my code into a Space?** `shark cp -r ./repo myspace:repo`, the files API (`PUT /v1/spaces/{id}/files`, archive upload), or `git clone` inside a command (GitHub is in the default egress allowlist). **What happens if two commands write the same file at the same time?** Both run; the first to finish lands, the second gets `shark_status: conflict` and its changes are not published. Re-run it. Commands touching different paths merge. **Does reading a file start a VM?** No. File reads, listings and downloads are served from the committed tree, and do not wake a hibernated Space. ## Reliability **What if my client crashes mid-command?** The command keeps running (it is a resource, not a connection). Re-attach with `shark logs ` or `GET /v1/spaces/{id}/ops/{op_id}/events`. Resending the same request with the same `op_id` never runs it twice. **What does `429 lease_cap_reached` mean?** Your account already runs the maximum number of commands at once. The command did **not** start. Wait `Retry-After` seconds and resend with the same `op_id`; the CLI and SDKs do this for you. **How long can a command run?** Sync calls up to 900 s; streaming or async calls up to 2 h. Asking for more is a `400` that tells you the maximum, never a silent cut. [limits.md](limits.md) **Is my data durable?** The Space's commits replicate in the background to an object store; `checkpoint` returns once the current state is stored. Until you checkpoint, the exposure is the replication lag, which the API reports. ## Agents **Does it work with Claude Code / Codex / Cursor?** Yes: install `shark`, paste the [AGENTS.md snippet](agents-md.md), and the agent runs its commands with `shark run`. Unmodified Claude Code running next to the Space (shell shim) is a self-hosted option. [agents.md](agents.md) **With LangChain, LangGraph, smolagents, Prefect?** The Python SDK has integrations for each. Beta. [integrations.md](integrations.md) **Is there an MCP server?** `sharkfn` serves Read/Write/Edit/Glob/Grep over MCP from a Space's tree (self-hosted, beta). For command execution, the CLI or a one-tool SDK integration is simpler and adds no tokens per request. **Can I use the E2B SDK unchanged?** Not yet; E2B compatibility is not served (`e2b_compat: false` in `/v1/capabilities`). The vocabulary map is in [concepts.md](concepts.md#vocabulary-shark-vs-e2b-daytona-modal-and-boat). ## Security and keys **Can my agent see my API tokens?** Not if you store them as Space secrets with `expose: inject` (the default): the command sees a placeholder and the node adds the real value on the way out, only toward the hosts you allow. **Can my agent reach the internet?** Within the Space's egress policy. The hosted default is an allowlist with package registries and GitHub; add hosts with `PUT /v1/spaces/{id}/egress`. Private ranges are always blocked. ## Account and billing **How do I get access?** Early access: request it at [sharkmagic.si](https://sharkmagic.si). Your invite has a key and the node URL. **How much does it cost?** Plans: Shark Pro $5/month includes $5 of usage; Shark Studio $25/month includes $25; Shark Scale $100/month includes $100. One active plan: included usage is spent first, then prepaid credits. Commands run on the standard 8 vCPU / 4 GiB shape at $0.12 per compute-hour, billed only while a command runs. Services are $0.03/h for 2 vCPU / 2 GiB and $0.06/h for 4 vCPU / 4 GiB (standard service rate $0.06/h). Storage is $0.08/GiB-month with the first 5 GiB free per Space per billing cycle; egress is $0.03/GB with the first 10 GiB free per Space per billing cycle. Overage only. Idle is not billed. [billing.md](billing.md#plans) **What happens when my credits run out?** New work answers `402 credits_exhausted` and your Spaces hibernate; files stay readable. Top up to continue. **Is there a free tier?** The Shark Pro plan includes $5 of usage each month, and storage and egress each have a free monthly allowance per Space. There is no separate free plan; promotions, when offered, are shown at checkout. **GPUs? Windows? Docker inside a Space?** No GPUs and no non-Linux guests. Docker inside a Space is not tested or documented today: do not rely on it. **Who do I contact?** Access: [sharkmagic.si](https://sharkmagic.si). Security reports: see [security.md](security.md). --- # Fit finder: where Shark fits in your agent stack Paste the prompt below into the coding agent that has your repo (Codex, Claude Code, Cursor…). It maps your deployed agents, finds where compute sits idle, and says where Shark fits, where it does not, and what it would take. It changes no code and asks for no secrets. Read its report yourself; send us only what you choose to (rough numbers are enough). --- ```text You are auditing this repository's deployed AI agents and workflows to find where per-command, serverless sandboxes would save money or remove infrastructure, and where they would not. Do not modify any file. Do not print secrets, API keys, customer names, customer data or prompts: refer to clients as "client A, B…" and to secrets by variable name. Background on the option being evaluated (Shark Magic): - Each agent/workflow gets a persistent "Space": files, installed dependencies and caches persist between runs. - Each command or code execution runs in a fresh Linux microVM (Firecracker) on that Space: normal bash, python, node, apt packages, outbound network. The VM exists only while the command runs (~44-65 ms overhead per command), so time spent waiting on the LLM, on APIs, on a queue or on a cron schedule costs nothing. - Postgres and Redis can run per Space as services that sleep when idle and wake on connection (~40 ms). - Many commands can run in parallel on separate Spaces or forks of one Space, so bursts do not need a queue sized for the biggest job. - Driven by HTTP API, Python/TypeScript SDKs or a CLI; coding agents (Claude Code, Codex) can point their shell at it. - Dev servers and APIs can run as exposed processes that sleep when idle, with private preview URLs (CLI and API; no SDK method yet). - Not available today: GPUs; a browser session kept open across steps (a headless browser that runs inside one command works); Windows/macOS. Do this: 1. Inventory. List every deployed agent, workflow, worker, cron job, queue consumer or serverless function in the repo (look at deployment files: Dockerfile, docker-compose, k8s/helm, Terraform, fly/railway/render configs, Prefect/Airflow/Temporal/Celery definitions, GitHub Actions, Procfiles, cron specs, Modal/E2B/Daytona usage). For each one record: name, trigger (user, webhook, cron, queue, email…), framework (smolagents, LangGraph, custom loop…), where it runs today and on what size (vCPU/RAM/disk), and which services it needs (Postgres, PostGIS, Redis, object storage, SharePoint, browser…). 2. Where compute is actually used. For each workflow, trace the steps of one run and classify each step as: LLM call | external API/web call | code execution (agent-written code, scripts) | file processing (download, unzip, parse documents) | browser automation | database query | waiting (queue, sleep, human). Point to the functions/files that do each step. Estimate (rough is fine, say how you estimated) the run's total duration and the share of it where a CPU is doing real work vs. waiting. 3. Idle cost. From deployment configs and any billing/infra notes in the repo, estimate what each workflow's compute costs per month and what share of that is paying for machines that are waiting. Note machines sized for the worst case (e.g. a large VM kept for one heavy job) and concurrency limits or queues that exist only because the infrastructure cannot run more at once. 4. Fit. For each workflow, give one verdict with a one-line reason: - FIT-EXEC: agent-written code or scripts run in a sandbox per execution (e.g. replace a local/Docker/E2B code executor; in smolagents this is the code executor backend). - FIT-SPACE: needs persistent files/deps/database between runs but sits idle most of the time. - FIT-BURST: heavy, spiky jobs (large downloads, unzip, parsing, builds, tests) that today wait in a queue or need an oversized machine. - NO-FIT: pure LLM/API orchestration with no code execution (a plain serverless function is cheaper), needs a GPU, needs a process to stay up continuously, or needs something listed above as not available. - UNSURE: say what information is missing. 5. Integration point. For each FIT, name the single place where the sandbox would plug in (file and function), what would be sent to it (command, files) and what comes back, and what secrets it would need (names only). Do not write the integration. 6. Report. Output a markdown report: a table (workflow | trigger | runs/day | duration | busy % | today's monthly cost | verdict | integration point), then the top 3 opportunities with estimated monthly savings ranges, then the risks and unknowns. Keep it under two pages. Mark every number as measured, from config, or estimated. ``` --- What we do with what you send: compare it against the measured profile of other agent workloads and tell you, in one page, what share of your bill is idle and what it would cost on Shark. If Shark is not cheaper or not a fit for your case, we say so. --- # Use case: parallel e2e (Playwright) and CI on Shark > **Planned, not yet measured.** This is a design note. Nothing on this page has been run or measured as a CI > workload. Only the building blocks it cites exist and are measured ([numbers.md](../numbers.md)); the end-to-end > numbers do not exist yet. ## The problem An e2e suite split into N shards wants N clean machines at the same moment, each with the app, its dependencies, a browser and a database, and then wants them gone. On always-on runners you pay for the runners while they wait; on fresh VMs you pay a boot, a checkout and a dependency install per shard. ## The design 1. **One Space per pipeline, prepared once.** A first command checks out the commit and installs dependencies (`git clone`, `npm ci`, `npx playwright install` within the egress policy). That Space's files are the checkout every shard reads. 2. **One copy per shard.** Each shard gets its own Space whose files start from the prepared one, so it can write freely (test artifacts, a modified `.env`) without touching the others. SharkFS is content-addressed, so a copy is a new reference to the same commit and only the bytes a shard writes are new. 3. **One VM per shard command.** `npx playwright test --shard=i/N` runs in its own lease. Shards start as a burst. 4. **Postgres per shard.** Each shard Space declares `[postgres] version = "17"` and gets its own database, already initialized, woken from a snapshot on first connection. No shared test database, no cleanup between shards. 5. **Collect and drop.** The CI job reads exit codes and outputs from the exec responses, copies reports out, and deletes the shard Spaces. ```sh # sketch of the per-shard call (the copy step is the missing piece, see below) curl -fsS -X POST "$SHARK_API/spaces/$SHARD_SPACE/exec" -H "Authorization: Bearer $SHARK_API_TOKEN" \ -d "{\"cmd\":\"npx playwright test --shard=$I/$N\",\"op_id\":\"$PIPELINE-shard-$I\",\"timeout_s\":110}" ``` ## What exists today, and what is missing | piece | today | |---|---| | fresh VM per command, bursts | exists; a burst of 100 creates + first command measured (P85) | | per-Space Postgres woken from snapshot | exists; measured with `psql` (P85 H5a) | | exactly-once retries by `op_id` | exists | | copy (fork) of a Space | exists in the Space library and local CLI only; **no API endpoint yet** | | shared read-only base for many Spaces | `-base-layer`, experimental, server-wide (not per pipeline) | | commands longer than the server's `-exec-timeout` (default 120 s) | not over the synchronous API; long suites need the timeout raised or splitting | | getting artifacts (reports, traces, videos) out | no download endpoint; today via a command (`cat`, `base64`) or by uploading from inside the VM where egress allows | | browsers in the lease image | not verified; depends on the guest root filesystem | | measured end-to-end CI times | **none** | ## What would be measured (to pre-register before any claim) Wall time of the full suite at N shards vs the same suite on the team's current runners; compute seconds billed per pipeline; time from "pipeline starts" to "all shards running"; flake rate vs current runners; failures per 1,000 shards. Thresholds are written before the first run; until then, no performance claim about CI.