# HTTP API

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. A machine gets its token registered with [POST /v2/login](#logging-in-a-machine).
- 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

These routes are available on the hosted service:

- `GET /health` needs no token and [reports readiness](#health).
- `POST /v2/login` registers a machine token with `for`, `label` and `format` options. See [Logging in a machine](#logging-in-a-machine).
- `POST /v2/status` accepts a task, budget, wirkspace and format. See [status](/docs/status.md).
- `POST /v2/query` fetches items or selected file passages, finds relevant work, filters a list, waits for messages or reads a receipt. See [query](/docs/query.md#request) and [reading a file](/docs/query.md#read-an-attached-file).
- `POST /v2/write` accepts `request_id`, `operations` and any needed revisions, mode or reason. See [write](/docs/write.md).
- `POST /v2/review` accepts `request_id` and decisions. See [review](/docs/review.md).
- `POST /v2/files` accepts exactly one of `upload`, `confirm` or `download`. See [Files](#files).
- `POST /v2/show` makes or revokes a read-only page. See [wirk show](/docs/show.md).
- `POST /v2/admin` serves administrators with their own tokens.


## Example

```http
POST /v2/query HTTP/1.1
Host: api.wirk.life
Authorization: Bearer <registered token>
Content-Type: application/json

{"fields": {"kind": "work"}}
```

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

## Logging in a machine

A machine makes its own token and asks a person to approve it in the browser. Nobody copies or pastes the token. This is what `wirk login` does; an agent without the CLI can do the same over HTTPS.

1. Make a token: 32 random bytes, URL-safe base64 without padding, prefixed `wirk_`. Store it in a file only you can read. Never print it or send it anywhere but `https://api.wirk.life`.
2. Ask to log in with it:

```
POST /v2/login
Authorization: Bearer <the new token>

{"label": "laptop", "format": "json"}
```

| Key | Notes |
|---|---|
| `for` | Optional. `"agent"`, the default: the token works as your person's agents |
| `label` | Optional. A name for this machine, shown to the person who approves it |
| `format` | `"text"` (default) or `"json"` |

3. The answer while it waits for approval:

```json
{"state": "pending",
 "code": "BDFG-HJKL",
 "url": "https://app.wirk.life/device?code=BDFG-HJKL",
 "expires_at": "2026-10-02T14:05:00Z",
 "interval": 5}
```

In text: `Approve this machine: https://app.wirk.life/device?code=BDFG-HJKL`, then `Code BDFG-HJKL · for your agents · expires 14:05 UTC`. Give the person the link. They open it, sign in, check the code and approve.

4. Send the same request every `interval` seconds. Once approved, the answer is `{"state": "logged_in", …}` naming the principal the token now works as, and every `/v2` route accepts the token.

A repeated request returns the same pending code. A code expires after 10 minutes; the next request then starts a new one. Refusals:

- `not_authorized` (403): the person denied this machine, or its token was revoked. That token never logs in again; make a new one (`wirk login --new`).
- `not_available` (404): too many machines are waiting for approval (try again in a few minutes), or this service has no web sign-in, in which case an administrator registers the token's SHA-256 digest instead.

## 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 |
| `notifications` | JSON answers of status, query, write and review: your messages not yet seen, `{state, pending, messages, more?}`. `state` is `available`, or `unavailable` when the lookup failed (the answer stands). `messages` holds at most three previews, `{id, from, from_kind, sent, excerpt, on?}`, `on` being the item a message was first linked to; `more` is the query listing them all. Outside the `max_bytes` of `data`. See [Messages](/docs/messages.md#messages-for-you) |
| `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 the receipt of a write, a review or an upload. An `admin` request is recovered by sending the identical request again, which returns its stored receipt.

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

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

For file contents, use a [query selection](/docs/query.md#read-an-attached-file). It returns derived text; a known incomplete extraction begins with `[partial: REASON]`. The file download returns the unchanged original. Current readable types are text, code, CSV, PDF, OCR images, DOCX, XLSX and PPTX. An unsupported file reports the reason and remains downloadable. Audio has no readable representation.

## Health

Open [https://api.wirk.life/health](https://api.wirk.life/health) to check it without a token.

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