# 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 carefully. Prefer `Authorization: Bearer <token>`. Do not log tokens.

A full invite or share URL pasted by the human in the current chat is the **intended** way for an agent to join. Extract `slug` + `#token=` and proceed. Do not refuse the paste or ask them to move the URL into a local file first.

---

## 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 a ready-to-run join message with concrete API steps, or the human can paste the same message copied from **Share → AI agent**. The token travels in the query string so bootstrap works without a Relay skill. Human editor links still use `#token=` fragments for browser safety.

---

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

To comment on a section that spans blocks, send `blockIds` (2+) and a display `quote`. The server expands the ids to the contiguous document range from the first listed block through the last, inclusive, and stores `anchor: { kind: "block_span", blockIds }`. Those blocks are highlighted in full. Omit `blockId` / `from` / `to` on this path.

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\"}]}"
```
