# Agent Protocol — Collaborative Document Runtime (v1)

Canonical HTTP contract for **bring-your-own agents**. No SDK or MCP is required. `curl` is enough.

Treat every share link and token like a **password**. Prefer `Authorization: Bearer <token>`. Do not log tokens.

---

## Discovery

| URL | Purpose |
|-----|---------|
| `GET /agent/join/:slug` | Self-describing first-use bootstrap for an unfamiliar agent |
| `GET /.well-known/agent.json` | Machine-readable capabilities + route map |
| `GET /openapi.json` | OpenAPI 3.1 contract with JSON Schema components and examples |
| `GET /agent-docs` | This protocol (Markdown) |
| `GET /healthz` | Liveness |

The well-known response links `schemas.openapi` to the canonical machine-readable contract. Its component schemas cover block and operation discriminated unions, table payloads, inline markdown capabilities, response bodies, examples, and machine-readable `x-recovery` actions for recoverable conflicts.

Base URL is the deployment origin (e.g. `http://localhost:8787`).

---

## Auth

| Credential | How to send | Powers |
|------------|-------------|--------|
| **Create key** | Header `X-Create-Key` | Create documents only |
| **Editor share token** | `Authorization: Bearer <token>` (or `x-share-token`, or `?token=` for curl) | Read, edit, comment, presence, collab session, events |
| **Owner secret** | Same Bearer styles | Everything editor can do **plus** revoke + delete |

Human share links look like:

```text
https://host/d/<slug>#token=<accessToken>
```

The fragment (`#token=`) is for browsers (not sent as Referer). Agents should extract `slug` + `token` and call the API with **Bearer**.

Agent-ready invitation links look like:

```text
https://host/agent/join/<slug>#token=<accessToken>
```

An unfamiliar HTTP-capable agent can fetch that URL to receive concise bootstrap instructions and links to this protocol, discovery, and OpenAPI. The fragment is not included in the HTTP request, so the agent must extract the token from the original URL it received. The editor's **Add agent** action copies this invitation. It reuses the document's editor capability in v1 and does not create an agent inbox or keep the agent running.

---

## Core loop (read → edit → confirm)

1. **Read** state or snapshot → capture `baseToken`, `revision`, content/blocks  
2. Plan changes (out of band — chat/skill session, not this product)  
3. **Edit** with `baseToken`, `by`, `Idempotency-Key`, and block operations  
4. On **409 `CONFLICT` or a guarded target conflict** → inspect the structured response, re-read if needed, and retry with a new idempotency key
5. **Confirm** via response body (read-your-writes) and/or `GET .../events?after=`

```text
re-read → baseToken → edit → confirm
```

Always send a stable agent identity in `by`, e.g. `ai:codex`, `ai:claude`.

---

## Routes

### Create (needs create key)

```http
POST /v1/docs
X-Create-Key: <create-key>
Content-Type: application/json

{}
```

**201** body:

```json
{
  "slug": "abc123",
  "docId": "uuid",
  "shareUrl": "https://host/d/abc123#token=...",
  "accessToken": "...",
  "ownerSecret": "...",
  "revision": 1
}
```

- Forward **shareUrl / accessToken** to humans and other agents.  
- **Persist ownerSecret** yourself. It cannot be recovered. Never forward it as a normal share link.

### Read

```http
GET /v1/docs/:slug/state
Authorization: Bearer <token>
```

Returns `markdown`, `marks`, `revision`, `accessEpoch`, `baseToken`, `role`, `presence`.

```http
GET /v1/docs/:slug/snapshot
Authorization: Bearer <token>
```

Returns the compatible full snapshot: `blocks[]` with stable `id`s (`b1`, …), `markdown`, `baseToken`, `revision`.

Table blocks are structured-only in v1:

```json
{
  "id": "b7",
  "type": "table",
  "text": "",
  "table": {
    "header": ["Name", "Status"],
    "rows": [["Alpha", "Ready"]]
  },
  "comments": []
}
```

For a table, `text: ""` is a compatibility sentinel—not editable text and not a cell address. Header and body values live only in `table.header[]` and `table.rows[][]`. Cells do not have IDs or direct edit/comment coordinates in v1.

Every complete block includes `comments: []` or the full structured comments whose `anchor.blockId` matches that block. Comments are ordered by `createdAt`, then `id`; orphaned structured comments remain on their referenced block while it exists. This applies to full, `blocks`, `context`, `section`, and complete `search` snapshot blocks and to successful edit response blocks. The compact outline and truncated search `{id,type}` placeholders omit comments. Quote-only legacy comments and structured comments whose block no longer exists remain available through `/state` `marks`.

For smaller re-reads, request a compact snapshot view. Compact responses omit the full `markdown` and always include current concurrency metadata (`revision`, `accessEpoch`, and `baseToken`):

```http
# Heading outline. Entries include block id, text, heading level, and 0-based index.
GET /v1/docs/:slug/snapshot?view=outline

# Selected blocks. IDs are comma-separated; results preserve requested order.
GET /v1/docs/:slug/snapshot?view=blocks&ids=b2,b8

# Anchor plus bounded neighboring blocks (defaults: before=2, after=2).
GET /v1/docs/:slug/snapshot?view=context&anchor=b8&before=2&after=3

# A heading and its complete deterministic subtree.
GET /v1/docs/:slug/snapshot?view=section&heading=b3
```

- `view=outline` returns `outline[]` and `totalBlocks`.
- `view=blocks` accepts 1–100 unique IDs and returns `blocks[]`, `requestedIds`, and `totalBlocks`.
- `view=context` returns the anchor and surrounding `blocks[]` in document order. `before` and `after` are integers from 0–20. Its `window` reports 0-based `startIndex`, exclusive `endIndexExclusive`, and `totalBlocks`.
- `view=section` requires a heading block ID in `heading`. It returns that heading plus every following complete structured block up to, but not including, the next heading of equal or higher level. Lower-level headings and their content remain in the section. `bounds.startIndex` is the selected heading's 0-based document index; `bounds.endIndexExclusive` is the boundary heading's index or `totalBlocks` for the final section. `headingId`, `headingLevel`, and top-level `totalBlocks` make the selected scope explicit. Adjacent equal-or-higher headings produce a one-block section.
- A missing requested or context anchor ID returns `404 BLOCK_NOT_FOUND` with `missingIds` plus the current `revision` and `baseToken`.
- A missing section heading ID returns `404 HEADING_NOT_FOUND`; an existing non-heading target returns `400 BLOCK_NOT_HEADING`. Both include current `revision` and `baseToken` plus recovery guidance to refresh `view=outline` and choose a heading ID.
- Invalid or mixed view parameters return `400 INVALID_READ_QUERY`.
- Omit `view` (or use `view=full`) for the original full snapshot response.

#### Export Markdown

Any valid document reader, including a viewer, can download the latest authoritative UTF-8 Markdown:

```http
GET /v1/docs/:slug/export?format=markdown
Authorization: Bearer <token>
```

The response uses `Content-Type: text/markdown; charset=utf-8` and an attachment filename derived from the first level-one heading, with the document slug as fallback. It reads the live collaborative document when a room is connected, so edits do not need to wait for the persisted projection. The Markdown contains canonical document body content only: comments, suggestions, presence, and event history are excluded.

#### Search current document

Search is another compact snapshot view and reads the same current authoritative/live document as the other snapshot modes:

```http
GET /v1/docs/:slug/snapshot?view=search&q=literal&limit=20&before=1&after=1
Authorization: Bearer <token>
```

Search v1 is a deterministic, case-insensitive literal substring search. It does not support regex, embeddings, semantic search, or cross-document search. Each result normally contains the complete structured matching block, match positions, and optional complete neighboring blocks.

```json
{
  "view": "search",
  "query": "literal",
  "revision": 7,
  "accessEpoch": 1,
  "baseToken": "mt1:...",
  "totalBlocks": 12,
  "matchedBlocks": 2,
  "results": [
    {
      "index": 4,
      "block": { "id": "b5", "type": "paragraph", "text": "A literal value", "comments": [] },
      "truncated": false,
      "matchCount": 1,
      "matchesTruncated": false,
      "matches": [
        { "path": ["text"], "start": 2, "end": 9, "text": "literal" }
      ],
      "context": { "before": [], "after": [], "omitted": false }
    }
  ],
  "nextCursor": null,
  "hasMore": false
}
```

- `q` is required, trimmed, and limited to 256 characters.
- `limit` bounds matching blocks per page (default 20, range 1–100).
- `maxBytes` bounds the UTF-8 JSON response body (default 65,536, range 4,096–262,144), including complete embedded comment objects.
- `before` and `after` request complete neighboring blocks (default 0, range 0–20). Context is returned as `{index, block}` entries. If the context group cannot fit the byte cap, it is omitted as a group and `context.omitted` is `true`; returned context blocks are never cut.
- `matches[].path` identifies the searched string field. Normal text/code uses `["text"]`; table cells use paths such as `["table","rows",0,1]`. `start` is inclusive and `end` exclusive in JavaScript UTF-16 code units. `text` contains the exact matched spelling.
- At most 25 positions are returned per matching block. `matchCount` reports the total and `matchesTruncated` makes omitted positions explicit.
- Pagination stops before the next complete matching block. When `hasMore` is true, pass `cursor=nextCursor`; cursors are 0-based block indexes. If `baseToken` changes between pages, restart the search to avoid skips after concurrent structural edits.
- If one matching block and all of its comments cannot fit by itself, its result has `truncated: true`, a minimal `{id,type}` block with no `comments`, `totalBytes`, and `fetch: {"view":"blocks","ids":"..."}`. Use that existing block view to fetch the complete block and whole comment objects separately; comments are never partially truncated.
- Invalid parameters return `400 INVALID_SEARCH_QUERY`.

### Edit (direct body writes; suggestions are out of scope)

```http
POST /v1/docs/:slug/edit
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: <unique-per-logical-mutation>

{
  "baseToken": "mt1:...",
  "by": "ai:your-agent",
  "operations": [
    { "op": "insert_after", "after": null, "type": "paragraph", "text": "Hello" },
    { "op": "replace_block", "blockId": "b1", "type": "paragraph", "text": "Updated" },
    { "op": "replace_text", "blockId": "b1", "oldText": "Updated", "newText": "Precisely updated" },
    { "op": "insert_before", "before": "b1", "type": "heading", "text": "Title" },
    { "op": "delete_block", "blockId": "b2" }
  ]
}
```

#### Mutation freshness and retry matrix

| Mutation | Batch classification | Freshness rule | Stale/conflict retry |
|---|---|---|---|
| `replace_text` | Guarded only when every operation is `replace_text` | The global `baseToken` may be stale when every authoritative target still satisfies `oldText` and optional `occurrence` | Inspect `snapshot?view=blocks&ids=<blockId>`, update the target precondition, and retry with its `baseToken` plus a new idempotency key |
| `replace_table_cell` | Guarded only when every operation is `replace_table_cell` | The global `baseToken` may be stale when every authoritative cell at its explicit coordinate still equals `oldText` | Inspect the current table block, update the coordinate or `oldText`, and retry with its `baseToken` plus a new idempotency key |
| `insert_after` | Structural | Current global `baseToken` required | Refresh the full snapshot, rebase all operations, and retry with a new key |
| `insert_before` | Structural | Current global `baseToken` required | Refresh the full snapshot, rebase all operations, and retry with a new key |
| `replace_block` | Structural | Current global `baseToken` required | Refresh the full snapshot, rebase all operations, and retry with a new key |
| `delete_block` | Structural | Current global `baseToken` required | Refresh the full snapshot, rebase all operations, and retry with a new key |

A mixed-operation batch, including one that combines `replace_text` with `replace_table_cell`, requires the current global `baseToken`. Do not reuse either guarded stale-token rule for a mixed batch.

| 409 error | Recovery action | Read scope |
|---|---|---|
| `CONFLICT` | `refresh_full_snapshot_and_rebase` | Full snapshot; structural references and ordering may have changed |
| `TARGET_CHANGED` | `inspect_target_and_update_precondition` | Compact `blocks` view for the target |
| `TARGET_AMBIGUOUS` | `inspect_target_and_set_occurrence` | Compact `blocks` view for the target |
| `TARGET_BLOCK_NOT_FOUND` | `refresh_full_snapshot_and_retarget` | Full snapshot; the old block ID cannot be fetched |
| `TABLE_CELL_CHANGED` | `inspect_table_cell_and_update_precondition` | Compact `blocks` view for the table |
| `TABLE_CELL_OUT_OF_BOUNDS` | `inspect_table_dimensions_and_retarget` | Compact `blocks` view for the table |
| `TABLE_CELL_TARGET_INVALID` | `inspect_table_block_and_retarget` | Compact `blocks` view; the block is no longer a table |
| `GUARDED_EDIT_FAILED` | `resolve_all_guarded_failures_and_retry` | Follow each entry's recovery scope, resolve every failure, then refresh before retrying the complete batch |
| `IDEMPOTENCY_KEY_REUSED` | `use_new_idempotency_key` | No content read required unless another error also requires one |

Every recoverable 409 body includes a machine-readable `recovery` object matching this table.

| Status | Meaning |
|--------|---------|
| 200 | Applied; body has new `revision`, `baseToken`, `markdown`, and complete `blocks` with `comments` |
| 409 | `CONFLICT`, `GUARDED_EDIT_FAILED`, single-operation guarded target/cell conflicts, or `IDEMPOTENCY_KEY_REUSED` |
| 400 | Missing idempotency key / bad ops, including `TABLE_TEXT_UNSUPPORTED`, `INVALID_TABLE_CELL_COORDINATES`, and `INVALID_TABLE_CELL_VALUE` |
| 401/403 | Auth |

`replace_text` is a guarded block-local operation:

- `oldText` matches the block's rendered text (inline markdown delimiters such as `**` are excluded).
- `newText` accepts inline markdown. When it has no explicit marks and the matched text has uniform marks, those marks are inherited.
- No match returns `TARGET_CHANGED` with `blockId`, `oldText`, `currentText`, current `baseToken`, and `revision`.
- Multiple matches return `TARGET_AMBIGUOUS` and `matchCount`. Supply `occurrence` as a positive, 1-based occurrence to disambiguate.
- A missing target block returns `TARGET_BLOCK_NOT_FOUND`.
- A batch containing only `replace_text` operations may proceed with a stale global `baseToken` if every target precondition still holds against the authoritative current Yjs document.
- A mixed batch containing any insert, whole-block replacement, or delete still requires the current global `baseToken`.
- Every batch is atomic: if one target precondition fails, no operation applies.

A batch containing more than one guarded operation preflights every guarded target against the same authoritative pre-batch document, including when structural operations are also present. Mixed batches still require the current global `baseToken` before this preflight. If any target fails, the server returns one `409 GUARDED_EDIT_FAILED` response:

```json
{
  "error": "GUARDED_EDIT_FAILED",
  "failures": [
    {
      "operationIndex": 1,
      "code": "TARGET_CHANGED",
      "blockId": "b3",
      "oldText": "old",
      "currentText": "current",
      "recovery": { "action": "inspect_target_and_update_precondition" }
    }
  ],
  "baseToken": "mt1:...",
  "revision": 8,
  "recovery": {
    "action": "resolve_all_guarded_failures_and_retry",
    "atomic": true
  }
}
```

`operationIndex` is zero-based and refers to the request's `operations` array. `failures` is ordered by that index and includes every detected changed, ambiguous, missing, wrong-type, or out-of-bounds target with its block or cell identity and current evidence. Valid operations are omitted from `failures`. If the array is non-empty, no operation applies and `revision`/`baseToken` remain unchanged. Resolve every entry, refresh the relevant snapshot, and retry the complete batch with the refreshed token and a new idempotency key. Repeating the original request with its original key replays the same aggregate 409 response. Single-operation guarded requests retain their direct target error shape.

This guarded-edit version intentionally exposes exact-text replacement rather than character ranges, so there is no offset convention to coordinate.

#### Replace a table cell

`replace_text` never addresses table cells. It fails with `400 TABLE_TEXT_UNSUPPORTED` and points to the purpose-built `replace_table_cell` operation:

```json
{
  "baseToken": "mt1:...",
  "by": "ai:your-agent",
  "operations": [
    {
      "op": "replace_table_cell",
      "blockId": "b7",
      "cell": { "section": "body", "row": 0, "column": 1 },
      "oldText": "Ready",
      "newText": "Done | verified"
    }
  ]
}
```

Coordinates are zero-based. A header coordinate is `{ "section": "header", "column": 1 }` and omits `row`; a body coordinate requires `{ "section": "body", "row": 0, "column": 1 }`. `oldText` must exactly equal the authoritative current cell, including literal pipe characters. `newText` is a single-line string; literal pipes are accepted and escaped only in the markdown projection.

A `replace_table_cell`-only batch may use a stale global `baseToken` when every coordinate still exists and every `oldText` guard still matches. Mismatch returns `409 TABLE_CELL_CHANGED` with `currentText` and the current cell evidence. Missing/non-table targets and out-of-range coordinates fail closed with `TARGET_BLOCK_NOT_FOUND`, `TABLE_CELL_TARGET_INVALID`, or `TABLE_CELL_OUT_OF_BOUNDS`. Malformed coordinates and non-string or multiline values return `400 INVALID_TABLE_CELL_COORDINATES` or `INVALID_TABLE_CELL_VALUE`. Every batch is atomic.

For row, column, or whole-table structural changes, continue to use `replace_block` with the complete `type: "table"`, `text: ""`, and `table` payload. Whole-table replacement requires the current global `baseToken`.

#### Edit nested lists

List block `level` is a zero-based nesting depth. Bullet, ordered, and task items may nest under one another, but every level-N item must have a preceding item at level N-1 in the same contiguous list sequence. List items must contain visible text; do not create an empty item solely to carry children.

Deleting a parent is valid when the remaining children can attach to a preceding adjacent list item at the required parent level. Replacing a parent with another list type preserves its nested children. If an operation would leave an orphaned level, invalid level jump, or empty item, the whole atomic batch fails with `400 INVALID_LIST_STRUCTURE`. The response includes `reason`, the affected block and level, and a machine-readable `recovery.action`; re-read before retrying.

`Idempotency-Key` is **required** and is bound to the canonical mutation request:

- Retrying the same key with the same request returns the same success body without double-applying.
- Object key order does not affect request identity; operation/array order does.
- Reusing the key on the same document and route with a different request returns `409 IDEMPOTENCY_KEY_REUSED` and applies nothing.
- Concurrent identical retries serialize and replay one result; concurrent different requests with one key allow only one request to apply.
- Aggregate `GUARDED_EDIT_FAILED` responses are also stored and replayed for the original request/key; retries cannot turn a rejected atomic batch into a partial or later application.

### Events (resumable cursor + bounded long polling)

```http
GET /v1/docs/:slug/events?after=0&limit=100&waitMs=25000
Authorization: Bearer <token>
```

```json
{
  "events": [
    {
      "id": 42,
      "type": "edit.applied",
      "revision": 7,
      "actor": { "type": "agent", "id": "ai:codex" },
      "changedBlockIds": ["b1", "b3"],
      "payload": {},
      "createdAt": "2026-07-25T20:00:00.000Z"
    }
  ],
  "nextCursor": 42,
  "hasMore": false
}
```

- `after` is the last event ID the caller consumed; it defaults to `0`.
- `limit` defaults to `100` and must be between `1` and `500`.
- `waitMs` defaults to `0` (immediate response) and may be at most `30000`. When no event is immediately available, the request waits until an event commits or the deadline expires.
- `nextCursor` is the last returned event ID, or the supplied `after` cursor when the page is empty.
- When `hasMore` is true, immediately request another page with `after=nextCursor` until drained. This avoids pagination gaps.
- `actor.type` distinguishes `agent`, `human`, and `system` activity. `actor.id` is included when available.
- Content events expose all affected block IDs in `changedBlockIds`, including inserted and deleted IDs.
- `edit.applied` is an agent HTTP write; `collab.stored` is a persisted human collaboration write.

Store `nextCursor` yourself. There is no ack endpoint in v1, and event reads do not imply presence.

### Comments

```http
POST /v1/docs/:slug/comments
Authorization: Bearer <token>
Idempotency-Key: <unique>
Content-Type: application/json

{
  "by": "ai:your-agent",
  "body": "Nit: clarify this",
  "blockId": "b12",
  "from": 8,
  "to": 20,
  "quote": "exact phrase"
}
```

For reliable block-local comments, send all four anchor fields. `from` and `to` are zero-based UTF-16 offsets into the exact `blocks[].text` returned by the snapshot API; `to` is exclusive. `quote` is expected evidence and must exactly equal that range. The server checks the current authoritative block, translates the range to document offsets for existing consumers, and returns the durable evidence as `comment.anchor`.

Quote-only requests remain compatible and are useful when the quote occurs exactly once:

```json
{
  "by": "ai:your-agent",
  "body": "Nit: clarify this",
  "quote": "unique substring from the doc"
}
```

For tables, quote-only comments are the supported path. Choose text that occurs exactly once in the projected `markdown` (typically a unique substring from one cell), and omit `blockId`, `from`, and `to`. Structured `block_range` anchors cannot address table cells and fail with `400 TABLE_BLOCK_RANGE_UNSUPPORTED`; the response tells the caller to retry with quote-only fields. Quotes containing a literal pipe must match its escaped spelling in projected markdown, so prefer another unique cell substring when possible.

| Status | Meaning |
|--------|---------|
| 201 | Comment created (`active` anchor) |
| 400 `INVALID_ANCHOR` | Structured evidence is incomplete or offsets are malformed |
| 400 `TABLE_BLOCK_RANGE_UNSUPPORTED` | Table cells have no block-range coordinates; retry with a unique quote-only anchor |
| 409 `ANCHOR_BLOCK_NOT_FOUND` | The supplied block no longer exists |
| 409 `ANCHOR_TARGET_CHANGED` | Range is stale or no longer equals the expected quote; response includes current evidence |
| 409 `IDEMPOTENCY_KEY_REUSED` | Key already belongs to a different comment request |
| 404 `ANCHOR_AMBIGUOUS` | Legacy quote-only anchor matches more than once — fail closed |
| 404 `ANCHOR_NOT_FOUND` | Legacy quote-only anchor is missing |

Comments continue to appear on `GET .../state` as document-wide `marks` (`kind: "comment") for backward compatibility. Structured marks also appear in their referenced complete block's `comments` array, including when orphaned; quote-only legacy marks stay document-wide because they have no `anchor.blockId`. Structured marks retain `anchor: { kind, blockId, from, to, quote }` and remain attached when unrelated blocks shift document offsets. If the target block or expected range changes later, the mark becomes `orphaned`; legacy quote-only marks retain their existing reanchoring behavior.

Commenters, editors, and owners can permanently delete a comment:

```http
DELETE /v1/docs/:slug/comments/:commentId
Authorization: Bearer <token>
Idempotency-Key: <unique-per-logical-deletion>
Content-Type: application/json

{ "by": "ai:your-agent" }
```

A successful deletion returns `deleted: true`, removes the mark from canonical Yjs and projected state, and emits `comment.deleted`. Retrying the same request replays its response. Deleting an already-absent comment is also a successful no-op with `deleted: false` and does not advance the revision or emit another event.

### Presence (explicit opt-in)

Reads and event polls **do not** mark you present or refresh a lease. In the invitation flow, successful “join” means the authenticated agent has explicitly posted a presence lease and received `200`; reading the document alone is not a join announcement.

```http
POST /v1/docs/:slug/presence
Authorization: Bearer <token>
X-Agent-Id: ai:your-agent
Content-Type: application/json

{
  "name": "Codex",
  "status": "editing",
  "intent": "Tightening deployment guidance",
  "targetBlockIds": ["b12", "b13"],
  "ttlSeconds": 60
}
```

`name` is the short, stable agent identity shown in the editor bar (for example, `Cursor` or `Claude`). Do not put task progress in `name`; use `intent` for current work such as “Tightening deployment guidance.” `intent` and `targetBlockIds` are optional collaboration hints. Intent is limited to 160 characters; a request may contain at most 20 current block IDs, with duplicates collapsed. Unknown targets fail with `409 PRESENCE_TARGET_NOT_FOUND`, while malformed metadata or a lease outside the allowed range fails with `400 INVALID_PRESENCE`.

Presence is a lease, not a durable status. `ttlSeconds` defaults to 60 and must be an integer from 5 through 300. The response and subsequent reads include `expiresAt`; while actively collaborating, refresh by posting the complete current metadata again before that time. Each post replaces the prior optional metadata, so omitting `intent` or `targetBlockIds` clears it. If the agent disconnects or crashes, stopping refresh is sufficient: the lease expires and disappears automatically. On a normal finish, leave immediately:

```http
POST /v1/docs/:slug/presence
Authorization: Bearer <token>
X-Agent-Id: ai:your-agent
Content-Type: application/json

{ "status": "leave" }
```

```http
GET /v1/docs/:slug/presence
Authorization: Bearer <token>
```

The presence list exposes `intent`, `targetBlockIds`, and `expiresAt` to humans and other agents when supplied.

### Owner control plane (owner secret)

```http
POST /v1/docs/:slug/revoke
Authorization: Bearer <ownerSecret>
```

Bumps `accessEpoch`, revokes editor links, disconnects collab.

```http
DELETE /v1/docs/:slug
Authorization: Bearer <ownerSecret>
```

Hard-deletes the document.

### Human collab (browsers)

```http
POST /v1/docs/:slug/collab-sessions
Authorization: Bearer <token>
```

Returns short-lived collab JWT + `wsUrl` for Hocuspocus. Agents normally **do not** need this.

---

## Second agent on a forwarded link

1. Receive the same editor share URL/token a human was given  
2. Extract `slug` + token  
3. `GET state` / `GET snapshot` with Bearer (this authenticates but does not announce presence)  
4. To join visibly, `POST presence` with a short stable name and task-specific intent  
5. Refresh before `expiresAt` while active  
6. Edit/comment with your own `by` (e.g. `ai:second-agent`)  
7. When finished, post `status: "leave"`; after a crash, let the lease expire  

No child-token minting is required in v1.

---

## Error shape

```json
{ "error": "CONFLICT" }
```

Common codes: `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `NOT_FOUND`, `INVALID_LIST_STRUCTURE`, `INVALID_ANCHOR`, `ANCHOR_BLOCK_NOT_FOUND`, `ANCHOR_TARGET_CHANGED`, `ANCHOR_AMBIGUOUS`, `ANCHOR_NOT_FOUND`, `TABLE_TEXT_UNSUPPORTED`, `INVALID_TABLE_CELL_COORDINATES`, `INVALID_TABLE_CELL_VALUE`, `TABLE_CELL_TARGET_INVALID`, `TABLE_CELL_OUT_OF_BOUNDS`, `TABLE_CELL_CHANGED`, `TABLE_BLOCK_RANGE_UNSUPPORTED`, `IDEMPOTENCY_KEY_REQUIRED`, `AGENT_ID_REQUIRED`.

---

## Non-goals (v1)

- SDK / MCP server  
- Suggestion accept/reject UX  
- Accounts / billing  
- Server-side event ack cursors  
- Task instructions inside the document product (keep those in chat)

---

## Quick curl sketch

```bash
BASE=http://localhost:8787
CREATE_KEY=dev-create-key

# create
curl -sS -X POST "$BASE/v1/docs" \
  -H "content-type: application/json" \
  -H "x-create-key: $CREATE_KEY" -d '{}'

# read
curl -sS "$BASE/v1/docs/$SLUG/state" -H "authorization: Bearer $TOKEN"

# edit
curl -sS -X POST "$BASE/v1/docs/$SLUG/edit" \
  -H "authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -H "idempotency-key: $(uuidgen)" \
  -d "{\"baseToken\":\"$BASE_TOKEN\",\"by\":\"ai:demo\",\"operations\":[{\"op\":\"insert_after\",\"after\":null,\"type\":\"paragraph\",\"text\":\"Hi\"}]}"
```
