# HTTP API

> **Preview.** This page describes the interface WIRK launches with. The hosted service is in private staging and access is by invitation. What is live today: /docs/index.md#what-is-live-today

The CLI and MCP server are thin clients over this API. Programs can call it directly.

- Base URL: `https://api.wirk.life`. Only `/health` and `/v2/…` are served.
- Every `/v2` request carries `Authorization: Bearer <token>`. `/health` needs none.
- Every body is JSON (`Content-Type: application/json`).
- Never follow a redirect with the token attached. WIRK's clients treat any redirect as an error.

## Routes

| Route | Body | State | Page |
|---|---|---|---|
| `GET /health` | none | Live | below |
| `POST /v2/status` | `{task?, workspace_id?, max_bytes?, format?}` | Live in staging | [status](/docs/status.md) |
| `POST /v2/query` | `{fetch? \| about? \| fields? \| receipt?, depth?, sort?, limit?, max_bytes?, cursor?, workspace_id?, format?}` | Live in staging | [query](/docs/query.md) |
| `POST /v2/write` | `{request_id, operations, expect?, mode?, reason?, workspace_id?, format?}` | Live in staging | [write](/docs/write.md) |
| `POST /v2/review` | `{request_id, decisions, workspace_id?, format?}` | Live in staging | [review](/docs/review.md) |
| `POST /v2/files` | exactly one of `upload`, `confirm`, `download` | Not live yet | [Files](#files) |
| `POST /v2/show` | `{preset} \| {title, blocks} \| {revoke}`, plus `timezone?, workspace_id?, format?` | Not live yet | [wirk show](/docs/show.md) |
| `POST /v2/admin` | `{show}` or `{request_id, operations}` | Not live yet | administrators only |

"Live in staging" means it answers for invited tokens while WIRK is in private staging.

## Example

```
curl -s https://api.wirk.life/v2/query \
  -H "Authorization: Bearer $WIRK_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"fields": {"kind": "work"}}'
```

Keep the token in a file readable only by you and out of command history, URLs, logs and WIRK itself.

## The envelope

Every `/v2` answer, refusals included, is one JSON object:

```json
{"ok": true,
 "text": "cards 1–3 of 26 · newest change first\n…",
 "errors": [],
 "notices": [],
 "page": {"complete": true, "next_cursor": null},
 "timing_ms": {"total": 4.2}}
```

| Key | Meaning |
|---|---|
| `ok` | `true` when the request did what it asked |
| `text` | The compact text answer, when `format` is `"text"` (the default) |
| `data` | The structured answer, when `format` is `"json"` |
| `errors` | Problems: `{code, message, field?, hint?, choices?, requested_id?, input_index?}` |
| `notices` | `{code, message}` that did not stop the request, such as `replayed` |
| `page` | `complete`, and `next_cursor` when more remain |
| `timing_ms` | Time spent on this request |

An answer without this envelope came from the network edge in front of WIRK, or from storage for a signed link, not from WIRK. HTTP statuses by code are listed in [Errors](/docs/errors.md).

## Text or JSON

`"format": "text"` (default) returns `text`: compact, readable, with 8-character short IDs. `"format": "json"` returns `data` with full IDs. `format` never changes what a request does, and an identical retry may switch format.

## IDs

Every position that names a record takes a full ID or a short one of at least 6 hex characters, with or without its prefix (`item_`, `link_`, `proposal_`, `upload_`; wirkspaces are `wsp_`). An ambiguous prefix is refused with `choices`. `workspace_id` is optional when your token belongs to one wirkspace; with several, `choose_wirkspace` lists them.

## Request IDs and receipts

`write`, `review`, `admin` and a file `confirm` require a `request_id` (1–200 characters) on the wire. The CLI and MCP server make one when you leave it out. The service stores a receipt per request ID:

- The identical body under the same ID returns the stored receipt and applies nothing again, even when the first request is still running.
- A different body under the same ID is `request_conflict`.
- `POST /v2/query {"receipt": "<request_id>"}` returns a receipt, including an upload's.

A `503 database_unavailable` envelope means the request was rolled back: a refusal, not an unknown outcome.

## The Wirk-Context header

`POST /v2/status` reads an optional header describing where the agent runs:

```
Wirk-Context: harness=claude-code version=2.1.281 session=7f3a1b2c9d0e4f56 repo=github.com/acme/api branch=main
```

| Key | Rule |
|---|---|
| `harness` | `[a-z0-9.-]`, 1–40 characters |
| `version` | `[A-Za-z0-9.+-]`, 1–40 characters |
| `session` | 16 lowercase hex characters |
| `repo` | `host/owner/name` in printable ASCII without `@` or `://`, or `local:` and 8 hex |
| `branch` | printable ASCII, at most 200 characters, none of `~^:?*[\` |

Each key at most once, the whole header under 2,048 bytes. If any part is invalid, the whole header is ignored with the notice `context_ignored`. The values are shown as reported and never decide what you may do.

## Files

> **Not live yet.** This is how `POST /v2/files` works at launch.

File bytes never pass through the WIRK server. One route, `POST /v2/files`, has three uses; send exactly one of `upload`, `confirm` and `download`, and always ask for `"format": "json"`, because text answers never print a signed link.

**1. Upload.** Ask for a place to put the bytes:

```
{"upload": {"bytes": 2202010,
            "sha256": "<64 hex>"},
 "format": "json"}
```

The answer's `data` is `{"present": true}` when storage already holds those bytes, or `{"present": false, "put": {"url", "headers", "expires_at"}}`: a signed PUT valid for 15 minutes. PUT the file to `put.url` with exactly `put.headers` and `Content-Length`, and never with your WIRK token. The signature covers the length and the SHA-256, so storage refuses any other bytes.

**2. Confirm.** Record the upload once the bytes are there:

```
{"request_id": "u-4b7e19c2a0",
 "confirm": {"filename": "report.pdf",
             "bytes": 2202010,
             "sha256": "<64 hex>",
             "description": "Load test results"},
 "format": "json"}
```

WIRK checks the stored object's size, SHA-256 and encryption, then answers `data.upload` = `{id, filename, media_type, bytes, sha256, description, object}`. The `request_id` makes the confirm safe to repeat. `upload_incomplete` means the bytes are not there yet: put them (with a fresh upload link if the first expired), then confirm again. Attach the upload in a write with `data.uploads` or `patch.attach_uploads`.

**3. Download.**

```
{"download": {"item": "5c1e7a90",
              "file": "file_…",
              "revision": 3},
 "format": "json"}
```

The answer's `data.download` is `{url, expires_at, filename, media_type, bytes, sha256}`: a signed GET valid for 5 minutes. Fetch it without your WIRK token and check the bytes' SHA-256 against `sha256`. `revision` is optional.

Never print, log or store a signed link. Each account's files are kept apart and encrypted with the account's own key. The upload limit is `max_upload_bytes`, 5 GiB by default. The CLI does all of this in `wirk upload` and `wirk download`.

## Health

```
curl https://api.wirk.life/health
```

Needs no token. Returns `{ok, instance_id, build, database: {ready}, files: {ready, reason}, limits: {…}, message}`, and HTTP 503 when the database is not ready. `limits` holds the live values of the [Limits](/docs/limits.md) the service enforces, such as `max_upload_bytes` and `max_write_operations`.
