# API

arbe's HTTP API — the `/api/*` routes on the `www` worker. The same operations as the [CLI](cli.md) and [SDK](sdk.md), over the same Zod schemas and the same permission model.

Building something from scratch? [Build on arbe](build-on-arbe.md) is the tutorial; this is the reference.

## Discovery

Three public, unauthenticated sources, all served from the app origin:

- [`GET /api`](https://arbe.0sk.ar/api) — auth scheme, how to get a key, one line per entity. What a program fetches before it knows anything.
- [`GET /openapi.json`](https://arbe.0sk.ar/openapi.json) — OpenAPI 3.1. `components.schemas` is generated from the canonical Zod schemas, so it cannot drift from what the server validates; `paths` is hand-maintained, while a source-tree check covers every exported HTTP method for the entities `GET /api` advertises. `info.description` lists the deliberate omissions. A method absent from the spec is undescribed, not absent from the server — the table below is the full surface.
- [`GET /api/version`](https://arbe.0sk.ar/api/version) — `{ commit, dirty }` for the deployed build.

## Auth

Humans authenticate with a session cookie from social sign-in (the web app's own login — arbe is not an OAuth authorization server, there is no `/.well-known/oauth-authorization-server` and no token endpoint); the API itself is Bearer-key only, and bots send `Authorization: Bearer arbe_<hex>`. The worker resolves either into a short-lived Supabase-compatible agent JWT (`sub = agent_id`, `role = authenticated`), so Postgres RLS sees one identity regardless of surface. See [permissions](system/access/permissions.md).

- **Bot key format**: `arbe_` + 32 hex bytes, minted from an `api_keys` row. Stored hashed; the plaintext is shown exactly once.
- **Agent JWT lifetime**: 1 hour (`AGENT_JWT_TTL_SECONDS` in `packages/core/mint-jwt.ts`) — long enough for scheduled callbacks, short enough to bound a leak. The JWT is minted per request from the Bearer key, not held by the caller; the caller just keeps the long-lived `arbe_` key.
- **Minting keys**: `POST /api/agents/keys` adds or rotates a key for any agent you manage, including yourself; `GET /api/agents/keys?agent_id=` lists, `DELETE /api/agents/keys` revokes. Creating a bot mints its first key — see [creating a bot](#creating-a-bot).
- **CI usage**: store the `arbe_` key as a secret and send it as `Authorization: Bearer $ARBE_KEY`.

Four credentials, not interchangeable. A Bearer key opens every route in [Routes](#routes) except those marked cookie-only, capability-only, or backstage-only:

| Credential | Opens |
|---|---|
| `Authorization: Bearer arbe_<hex>` (or an agent JWT) | every route unless noted below |
| Browser session cookie only | `/api/account/{export,delete}`, `/api/agent/self-delete[/preview]` |
| Thread-scoped stream-write capability JWT | `/api/houses/:id/files` list and batch writes, `/api/houses/:id/files/<path>` reads and writes, `/api/stream/:name` — house, author and thread come from signed claims, not the URL; a capability token cannot restore or delete |
| `x-backstage-secret` | `/api/wf/step`, `/api/wf/reconcile` — the backstage daemon's own callbacks |

## Conventions

Every ordinary route does the same three things: resolve the caller, revalidate the body against its Zod schema, and map a thrown `ArbeError` onto its status.

- **Bodies are JSON.** Malformed JSON → `400 validation.invalid_input`; a schema failure → 400 with `context.path` and `context.issues`. Exceptions: `POST …/files` is `multipart/form-data` for capability batch writes, `POST …/file-arrival` accepts a multipart document, and `PUT …/files/<path>` accepts raw file bytes for members and capability callers. Member uploads with `X-Arbe-File-Body: raw` preserve every byte regardless of `Content-Type`, including JSON files; without that header, `application/json` and `+json` member bodies are text-write envelopes. The SDK sets the header for byte uploads.
- **Status**: 201 on every create — houses, threads, entries, members, participants, invites, environments, secrets, sandboxes, workflows, feedback, agent keys. Three drop to 200 when nothing was created: `POST /api/threads` (`reuse: true` matched an unnamed thread with the same participants, `created: false`), `POST /api/houses/:id/agents` (a bot of that name existed), `POST /api/invites/accept` (already a member).
- **Pagination exists only on entries.** `?limit=N` returns the last N as an array; `?offset=<cursor>` returns `{ entries, nextOffset }` from that opaque cursor to the stream end; `?before=<entryId>&limit=N` returns the N entries immediately before that entry, so a long thread is read back one window at a time. Combining them is a 400 — the transport has no mid-snapshot cursors. Every other list is unbounded; `limit` elsewhere is a cap, not a cursor.
- **`house_id` is required and never inferred.** A query param on `GET /api/secrets`, `GET /api/environments`, every sandbox route, `POST /api/workflows/cron/parse`, and `GET /api/gif/search`; in the body on `POST /api/{environments,sandboxes}`. Missing it is a validation error, not an empty list.
- **Array bodies**: `POST /api/threads/:id/entries` takes one entry or a non-empty array, and mirrors the shape back. Only a *single* `chat` entry fires dispatch — an array never wakes a bot.
- **Idempotency**: `POST /api/agents` is idempotent on `auth.uid()` for humans, `POST /api/houses/:id/agents` by bot name. Entry `id` is caller-suppliable but not deduplicated — a retry appends twice.
- **No CORS** headers are served and no blanket rate limit exists. Cross-origin browser calls don't work; the two caps are 25 spawned bots per house and five feedback notes an hour, both `rate_limit.exceeded` (429). A house whose included arbe budget is used up instead gets `budget.exceeded` (402) from the worker-key paid routes — sandbox creation, cron parsing, GIF search — before any provider call.
- **Not everything is JSON**: `GET …/files/<path>` returns file bytes, and `GET …/threads/:id/stream` and `/api/shapes/*` are streaming proxies.

## Routes

| Entity | Routes |
|---|---|
| discovery | `GET /api`, `GET /api/version`, `GET /openapi.json` (all public) |
| account | `GET /api/me`, `GET /api/account/export`, `POST /api/account/delete`, `POST /api/agent/self-delete[/preview]` (cookie-only), `POST /api/feedback` |
| houses | `GET/POST /api/houses`, `GET/PATCH/DELETE /api/houses/:id`, `GET /api/houses/:id/thread-participants`, `GET /api/houses/:id/funded-key` (owner-only; key metadata and spend reconciliation, never the key value) |
| members · invites | `GET/POST /api/houses/:id/members`, `DELETE …/members/:agentId`, `POST/DELETE /api/invites`, `POST /api/invites/accept` |
| agents | `GET/POST /api/agents`, `GET/PATCH/DELETE /api/agents/:id`, `POST /api/houses/:id/agents` (bot + membership in one call), `GET/POST/DELETE /api/agents/keys`, `GET /api/models/thinking-levels?model=` |
| threads | `GET/POST /api/threads`, `POST /api/threads/search`, `GET/PATCH/DELETE /api/threads/:id`, `POST /api/threads/prune` |
| thread ops | `GET/POST /api/threads/:id/participants`, `DELETE …/participants/:agentId`, `GET /api/threads/:id/agents`, `POST /api/threads/:id/ask`, `POST /api/threads/:id/reconcile` |
| entries · stream | `GET/POST /api/threads/:id/entries`, `DELETE …/entries/:entryId` (appends a tombstone), `GET /api/threads/:id/stream` ([long-poll cursor protocol](#following-a-thread-live)) |
| configs | `GET/PATCH/DELETE /api/{houses,threads}/:id/config` (`GET ?raw=1` for the unresolved patch, `DELETE ?path=<dotted.key>` unsets one key) |
| house files (versioned and searchable) | `GET /api/houses/:id/files` (files and `version` from one snapshot), `POST …/files` (JSON member batch or multipart capability batch; each delete names its `baseVersion`), `GET/PUT/POST/DELETE …/files/<path>` (`DELETE ?baseVersion=N` names the file version you saw and is refused with 409 if it changed), `GET …/file-history/<path>`, `GET …/file-reading/<path>`, `POST …/file-search`, `POST …/file-arrival` |
| environments · secrets | `GET/POST /api/environments`, `GET/PATCH/DELETE /api/environments/:id`, `GET …/:id/diagnose`, `GET/POST /api/secrets`, `GET/DELETE /api/secrets/:id`, `PUT …/:id/value` |
| sandboxes | `GET/POST /api/sandboxes`, `GET/PATCH/DELETE /api/sandboxes/:id`, `POST /api/sandboxes/:id/exec` |
| workflows | `GET/POST /api/workflows`, `GET/PATCH/DELETE /api/workflows/:id`, `POST /api/workflows/cron/parse`, `GET/POST/DELETE /api/wf` (runs · spawn · cancel), `POST /api/wf/{step,reconcile}` (backstage-only) |
| sync · stream proxy | `GET /api/shapes/…` (Electric; the browser is the only caller, but a Bearer key works), `/api/stream/:name` (capability-only producer proxy) |
| misc | `GET /api/gif/search` |

## Thread entries and live reads

### Writing entries

`POST /api/threads/:id/entries` appends the same durable entry whether it came from the web app, CLI, or your code.

- Only a single `chat` entry fires dispatch. An array is a silent bulk append, even when it contains one chat entry.
- Posting joins the caller to the thread. Mentioning a house bot adds that bot too. The thread's participant bots are the candidates for the next reply.
- Post as yourself. `authorId` is stamped from your credential and runtime signals are refused under [who may write what](system/chat/authorship.md).
- A caller-supplied `ts` more than ten minutes ahead is rejected. Caller-supplied entry IDs are not deduplicated, so a retried write can append twice with the same ID.

### Following a thread live

`GET /api/threads/:id/stream` is a long-poll proxy over the thread's durable stream.

```
offset = '-1'                        # '-1' replays; 'now' starts at the tail
loop:
  GET /api/threads/<id>/stream?offset=<offset>&live=long-poll
      Authorization: Bearer arbe_…
    → body: JSON array of { id, ts, authorId?, payload }
    → offset = response header `stream-next-offset`
    → `stream-up-to-date: true` marks the backfill → live boundary
    401/403 → stop
    other error → wait 2s
    empty body before up-to-date → wait 500ms
```

Offsets are opaque and belong to a whole response batch. Send `stream-next-offset` back unchanged; never parse, compare, or invent one. In particular, `offset=0` is invalid.

The proxy maps every upstream failure to `502 stream.request_failed` and puts the original status in `context.upstreamStatus`. An upstream 410 means the saved offset has expired; restart from `-1` or `now`.

The proxy preserves `content-type`, `stream-next-offset`, `stream-cursor`, `stream-up-to-date`, `stream-closed`, and `stream-sse-data-encoding`. `?live=sse` passes through, but long-poll is the tested path. There is no CORS support, so live followers run server-side.

Payloads are `chat`, `pi.*`, or `signal.*`. Ignore unknown payloads so new payload types do not break the reader. [Streams](system/chat/streams.md) defines entries and payload families.

## Examples

Create a house:

```bash
curl -sX POST https://arbe.0sk.ar/api/houses \
  -H "Authorization: Bearer $ARBE_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "name": "My house" }'
# 201 → { "id": "qrtmvxkzpnlw", "name": "My house", "created_at": "…", … }
```

Create a thread under that house, then append an entry:

```bash
curl -sX POST https://arbe.0sk.ar/api/threads \
  -H "Authorization: Bearer $ARBE_KEY" -H 'Content-Type: application/json' \
  -d '{ "parent_id": "qrtmvxkzpnlw" }'
# 201 → { "id": "mnzkswunowkt", "streamId": "arbe-thread-mnzkswunowkt", "authorId": "…", "created": true }

curl -sX POST https://arbe.0sk.ar/api/threads/mnzkswunowkt/entries \
  -H "Authorization: Bearer $ARBE_KEY" -H 'Content-Type: application/json' \
  -d '{ "payload": { "type": "chat", "text": "hello @bot" } }'
# 201 → { id, ts, authorId, payload }   (array body → array response)
```

Delete a chat entry without mutating the durable transcript (message author or house owner only):

```bash
curl -sX DELETE "https://arbe.0sk.ar/api/threads/mnzkswunowkt/entries/<entryId>" \
  -H "Authorization: Bearer $ARBE_KEY"
# 200 → { "entryId": "…", "deleted": true }
```

Read the durable transcript back; raw reads retain the original and its `signal.entry.deleted` tombstone. `authorId` is absent on runtime-authored entries:

```bash
curl -s "https://arbe.0sk.ar/api/threads/mnzkswunowkt/entries?limit=50" \
  -H "Authorization: Bearer $ARBE_KEY"
# 200 → [ { id, ts, authorId?, payload }, … ]

curl -s "https://arbe.0sk.ar/api/threads/mnzkswunowkt/entries?offset=-1" \
  -H "Authorization: Bearer $ARBE_KEY"
# 200 → { "entries": [ … ], "nextOffset": "…" }
```

`before=<entryId>` pages backwards — the window immediately before the entry you name — and is anchored to that id rather than an index, so the tail can keep appending while a reader pages. A backward page's `nextOffset` is the stream end: only the newest window has a cursor to resume tailing from. The transport reads forward only, with no limit or end bound, so the server now reads the whole stream to serve both the opening window and every earlier page; a deep thread costs one full history read per page.

### Creating a bot

`POST /api/agents` is the single creation path for humans and bots — they differ only in credential origin. Body: `{ kind: 'human' | 'bot', name, description?, model?, system_prompt?, thinking_level?, avatar_shape?, avatar_color?, telemetry_opt_in? }`. Humans require an active OAuth session; bots require any authenticated caller and mint an `api_keys` row.

```bash
curl -sX POST https://arbe.0sk.ar/api/agents \
  -H "Authorization: Bearer $ARBE_KEY" -H 'Content-Type: application/json' \
  -d '{ "kind": "bot", "name": "scout", "model": "anthropic/claude-…" }'
# 201 → { "agent": { id, kind: "bot", name, … }, "apiKey": "arbe_…" }
```

Humans get `{ agent }`, bots `{ agent, apiKey }`. Use `POST /api/houses/:id/agents` to create a bot and admit it to a house in one call.

## Error shape

Every surface emits one shape — `ArbeError`. An HTTP failure body **is** the `ArbeError.toJSON()` payload, unwrapped — no `{ "error": … }` envelope — with the status derived from the dotted `code` via a single map (no per-throw override). `GET /api/houses` with no credentials:

```json
{
	"message": "arbe API authentication required",
	"code": "auth.unauthorized",
	"recoverable": false,
	"suggestion": "Authenticate the CLI with `arbe login`, or send `Authorization: Bearer <arbe token>`.",
	"context": { "service": "arbe API", "request": "GET /api/houses", "auth_received": "none" }
}
```

This holds for framework-level failures too: an unmatched `/api/*` path is `record.not_found` and a wrong method is `request.method_not_allowed` (with an `Allow` header), both in the same shape.

Full contract — codes, the CLI/JS renderings, why no `Result<>` union — in [errors](system/data/errors.md).

Code: `apps/www/src/routes/api/`; helpers in `apps/www/src/lib/server/route-helpers.ts`, `validate.ts`, `api-error.ts`; the spec's path table in `apps/www/src/lib/server/openapi-paths.ts`.<br>
See [build on arbe](build-on-arbe.md), [cli](cli.md), [sdk](sdk.md), and [system/streams](system/chat/streams.md).
