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

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<Record<string, any>>
```

Your account (`tenant`), plan, and the credential's id, kind, scopes and expiry (`GET /me`).

### `shark.capabilities`

```ts
shark.capabilities(): Promise<Record<string, any>>
```

The node's API version, features, limits and lease shapes. Read features from here rather than assuming them.

### `shark.limits`

```ts
shark.limits(): Promise<any[]>
```

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<Uint8Array>): AsyncGenerator<SseEvent>
```

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<Space>
```

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<Space>
```

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<Space>
```

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<string, string>
```

### `space.profile`

```ts
space.profile: string
```

`sdk` or `devbox` (Spaces made before profiles report `devbox`).

### `space.refresh`

```ts
space.refresh(): Promise<Space>
```

Re-reads the Space, including `settings`. `space.data` holds the raw JSON.

### `space.settings`

```ts
space.settings(): Promise<Record<string, any>>
```

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<Space>
```

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<Space[]>
```

`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<Space>
```

Hibernates now (any later request wakes it). Throws `ConflictError` while something runs.

### `space.wake`

```ts
space.wake(): Promise<Record<string, any>>
```

Wakes now; the result has `wake_ms`. Not needed before a command: any request wakes the Space.

### `space.checkpoint`

```ts
space.checkpoint(): Promise<Record<string, any>>
```

Resolves once HEAD and its objects are in the durable store. Throws `DurableUnavailableError`.

### `space.delete`

```ts
space.delete(options?: { purge }): Promise<void>
```

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<Record<string, any>>
```

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<ExecResult>
```

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<SseEvent>
```

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<Op>
```

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<Op>
```

Re-attaches to an op by id. Throws `NotFoundError`, `OpExpiredError` (output past 7 days).

### `space.ops`

```ts
space.ops(options?: { state, limit }): Promise<any[]>
```

Recent ops, newest first (`state`: `"running"` or `"done"`).

### `op.refresh`

```ts
op.refresh(): Promise<Op>
```

Re-reads the op into `op.data` (`state`, `cmd`, `result`). `op.opId` and `op.space` are fields.

### `op.events`

```ts
op.events(after?): AsyncGenerator<SseEvent>
```

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<ExecResult>
```

Follows the event stream to the end and returns the result.

### `op.signal`

```ts
op.signal(sig?): Promise<void>
```

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<Uint8Array>
```

Throws `NotFoundError`.

### `space.files.readText`

```ts
space.files.readText(path: string, at?: string): Promise<string>
```

```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<Record<string, any>>
```

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<Record<string, string | null>>
```

`{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<FileEntry[]>
```

The tree under `path` (default `"/"`), pages followed. `glob`: e.g. `"**/*.ts"`.

### `space.files.remove`

```ts
space.files.remove(path: string, options?: { recursive, opId }): Promise<Record<string, any>>
```

### `space.files.move`

```ts
space.files.move(from: string, to: string, options?: { overwrite, opId }): Promise<Record<string, any>>
```

### `space.files.uploadArchive`

```ts
space.files.uploadArchive(path: string, tar: Uint8Array, options?: { replace, opId }): Promise<Record<string, any>>
```

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<Uint8Array>
```

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:<port>` ([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<any[]>
```

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://<space-id>-<port>.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<any>
```

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<any[]>
```

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<any>
```

### `space.ports.setPublic`

```ts
space.ports.setPublic(port: number, isPublic: boolean): Promise<any>
```

`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<any>
```

Sleeps now; the next request wakes it.

### `space.ports.logs`

```ts
space.ports.logs(port: number): Promise<string>
```

### `space.ports.remove`

```ts
space.ports.remove(port: number): Promise<void>
```

Stops it and revokes every preview cookie.

### `space.ports.loginLink`

```ts
space.ports.loginLink(port: number, options?: { path }): Promise<any>
```

`{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<any[]>
```

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<any>
```

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<any>
```

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<any>
```

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<any[]>
```

The provider catalog: `name`, `suggested_name`, `hosts`, `in`, `note`.

### `secrets.list`

```ts
secrets.list(): Promise<any[]>
```

Names, `expose`, `hosts`, `in`, `source` (`space` or `account`), `shadowed`, `missing`, `excluded`, `apply`: never
values.

### `secrets.get`

```ts
secrets.get(name: string): Promise<any>
```

One entry of `list()` (the one in effect). Throws `NotFoundError`.

### `secrets.rotate`

```ts
secrets.rotate(name: string, value: string): Promise<any>
```

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<any>
```

Changes hosts, locations, exposure or `apply` and keeps the stored value.

### `secrets.remove`

```ts
secrets.remove(name: string): Promise<void>
```

Removing a name that is not there is not an error.

### `secrets.usage`

```ts
secrets.usage(name: string): Promise<any>
```

Per host (and, for an account secret, per Space): `count`, `blocked`, `last_used`.

### `secrets.status`

```ts
secrets.status(options?: { agents, presets }): Promise<any>
```

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<any>
```

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<any>
```

`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<any[]>
```

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<Record<string, any>>
```

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<Record<string, any>>
```

Replaces the policy (`PUT`); a field left out keeps its current value.

### `space.egress.use`

```ts
space.egress.use(...changes: string[]): Promise<Record<string, any>>
```

`"+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<Record<string, any>>
```

Adds hosts of the Space's own.

### `space.egress.disallow`

```ts
space.egress.disallow(...hosts: string[]): Promise<Record<string, any>>
```

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<Record<string, any>>
```

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<Record<string, any>>
```

Drops deny entries.

### `space.egress.denials`

```ts
space.egress.denials(options?: { since }): Promise<any[]>
```

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<any>
```

`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<any>
```

`{registered, app_id, slug, linked_spaces}`, never the key.

### `shark.githubApp.remove`

```ts
shark.githubApp.remove(): Promise<any>
```

Forgets the App and every Space link to it.

### `shark.githubApp.link`

```ts
shark.githubApp.link(space: Space | string, installationId: number, repos?: string[]): Promise<any>
```

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<any>
```

**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<Record<string, any>>
```

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<any[]>
```

Never the secret.

### `shark.keys.create`

```ts
shark.keys.create(options?: { name, scopes }): Promise<any>
```

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<any>
```

A new secret; the old one keeps working for `overlapS` seconds (default 86,400).

### `shark.keys.revoke`

```ts
shark.keys.revoke(keyId: string): Promise<void>
```

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