# write

`write` changes items and links in one atomic batch: every operation applies, or none does.

## Request

```
POST /v2/write
Authorization: Bearer <token>

{"request_id": "w-3f9a2c41d0",
 "expect": {"5c1e7a90": 3},
 "operations": [Operation, …]}
```

| Key | Notes |
|---|---|
| `operations` | Required. 1–32, applied in order, all or nothing |
| `request_id` | 1–200 characters. Required on the wire; the CLI and MCP server make one when you leave it out, and print it |
| `expect` | The revision you read of each record the batch depends on; at most 64 entries |
| `mode` | `"apply"` (default) or `"propose"` |
| `reason` | Required to propose, to archive, to override a duplicate, and to complete work (the evidence). At most 2,048 characters |
| `workspace_id` | Optional; default: your only wirkspace |
| `format` | `"text"` (default) or `"json"` |

## Operations

Use an `op` in each entry of `operations`:

- `item.create`: `data` is ItemData. Optional `ref` names the new item for later operations in the same batch; optional `allow_duplicate_of` lists IDs you have checked as likely duplicates.
- `item.edit`: `id` names the item and `patch` is ItemPatch.
- `item.archive`: `id` names the item; give the write a `reason`.
- `item.restore`: `id` names the archived item.
- `link.create`: `data` holds a link [type and endpoints](#links).
- `link.remove`: `id` names the link.
- `message.send`: `data.body` is required. Optional data keys are `title`, `to` (principal IDs), `reply_to`, and `uploads`. Optional `ref` names the new message.
- `message.acknowledge`: `messages` is a list of message IDs addressed to you or on the board of an item you own.

For example, create an item and link it in the same batch:

```json
{
  "request_id": "w-9b8e31c4a0",
  "operations": [
    {
      "op": "item.create",
      "ref": "new",
      "data": {"title": "Webhook retry report"}
    },
    {
      "op": "link.create",
      "data": {
        "type": "related_to",
        "from": "$new",
        "to": "5c1e7a90"
      }
    }
  ]
}
```

**ItemData** (create): `title`, `body`, `work` or `context` (at most one), `fields`, `uploads` (upload IDs), `folder_id`, `is_folder`.

**ItemPatch** (edit): `title`, `body` (replaces the body), `work`, `context`, `fields`, `attach_uploads`, `detach_file_ids`, `folder_id`.

- `work` is `{"owner_id": "…", "due_at": "2026-10-15T17:00:00Z", "criteria": [{"text": "…"}]}`, every key optional. Giving `work` makes an item of kind `work`; `{}` is enough. An edit's `work` **merges**: changing the owner keeps the criteria and due date. `"work": null` removes it. `owner_id` must be a member (`"me"` means you); `null` unassigns. The owner is spelled three ways, none of which changes: `work.owner_id` in a request body, `owner=me` (or `owner=ID`) in the CLI, and `owner` at the top level of what `query` returns, beside `fields`.
- `context` is `{"level": "organization" | "initiative", "state": "active", "steward_id": "alice", "open": false}`; only `level` is required. It makes an item of kind `context`. An initiative's state defaults to `active`; `steward_id` names who maintains it (default: you, or the person whose agent you are); `open: true` lets everyone with edit rights change it. On edit, send `level` unchanged; omitted `steward_id` and `open` keep their values. Creating context is for administrators and their agents; anyone else's write is refused with `requires_review` and should be proposed.
- An item with neither `work` nor `context` is a `record`, or a folder with `"is_folder": true`.
- `fields` is the same flat map as in [query](/docs/query.md#filters): `{"status": "completed", "priority": "low"}`, a list for a many-value field, `null` to clear.
- `ref` names a new item so later operations in the same batch can point at it as `"$new"`.
- The two message operations apply directly and are never proposed; `item.edit` refuses a message, which never changes once sent. The CLI sends them with `wirk write message` and `wirk write seen`. See [Messages](/docs/messages.md).

## Links

Every `link.create` data object has `type`, `from`, and `to`. Its other keys depend on the type:

- `related_to`: Connects two items without a direction of responsibility. No other keys.
- `contributes_to`: `from` is the child and `to` is the parent. Optional `contribution` describes the relationship; optional `criteria` is a list of objects with `text`. An initiative accepts `contribution` only.
- `requires`: `from` is work and `to` is prerequisite work. Optional `criterion_id` names the criterion blocked by that prerequisite.
- `cites`: `from` cites evidence at `to`. Give `target_revision`, a `selector`, and `relation` (`supports`, `contradicts`, or `background`). Optional `quotation` must match the selected text exactly.
- `relies_on`: `from` depends on a source at `to`. Give `target_revision` and a one-line `assumption`.

A **selector** says which part of the cited item is meant:

- **Whole body:** `type` is `whole`; `part` is `{"type": "body"}`.
- **Whole file:** `type` is `whole`; `part` has `type: file` and `file_id`.
- **Text in a body or readable file:** `type` is `text`; `part` names the body or a file with `file_id`; `start` and `end` are offsets.
- **Lines in a body or readable file:** `type` is `lines`; `part` names the body or a file with `file_id`; `first` and `last` are line numbers.
- **File pages:** `type` is `pages`; give `file_id`, `first`, and `last`.
- **Audio or video time:** `type` is `time`; give `file_id`, `start_ms`, and `end_ms`.
- **Image region:** `type` is `region`; give `file_id`, `x`, `y`, `width`, and `height` as fractions of the image.

A `quotation` is optional. When given, it must equal the selected text exactly, or the link is refused with `quotation_mismatch`. It can be checked on `text` and `lines` selectors and on a `whole` selector that names a `part` (the body, or a file with readable text). `text` offsets count Unicode code points, not bytes, and `end` is exclusive. `lines` are 1-based and inclusive, and the selected text keeps each line's line break. On `pages`, `time`, `region` or a `whole` without a part, a quotation is refused with `representation_unavailable`.

### Cite a PDF excerpt

This synthetic example uses `report.pdf` attached to source item `8d24f6b1` at r2. Suppose its derived text is:

```
[partial: page 2 has no text]
[page 1]
The contract is signed.
```

The first line says extraction was partial because page 2 had no text; it counts as line 1. Read line 3 at that source revision with the full file ID from JSON `attachments`:

```json
{
  "fetch": [
    {
      "ref": "8d24f6b1",
      "revision": 2,
      "select": [
        {
          "type": "lines",
          "part": {
            "type": "file",
            "file_id": "file_4e1f0a2b111111111111111111111111"
          },
          "first": 3,
          "last": 3
        }
      ]
    }
  ]
}
```

The selected passage is `The contract is signed.` **with its trailing newline**. If that matches the readback, cite it from work item `5c1e7a90` at r3. The `\n` in `quotation` is the newline, and `target_revision` pins the source to r2:

```json
{
  "request_id": "w-6a90e2b1c4",
  "expect": {"5c1e7a90": 3},
  "operations": [
    {
      "op": "link.create",
      "data": {
        "type": "cites",
        "from": "5c1e7a90",
        "to": "8d24f6b1",
        "target_revision": 2,
        "selector": {
          "type": "lines",
          "part": {
            "type": "file",
            "file_id": "file_4e1f0a2b111111111111111111111111"
          },
          "first": 3,
          "last": 3
        },
        "relation": "supports",
        "quotation": "The contract is signed.\n"
      }
    }
  ]
}
```

Send the first body with `wirk query --request FILE` and the second with `wirk write --request FILE`. A different line or missing newline is refused with `quotation_mismatch`; read the extracted passage again rather than guessing offsets.

## expect

`expect` maps each record to the revision you read, the `rN` on its card or fetch: `{"5c1e7a90": 3}`. Full or short IDs work. Include:

- every item you edit, archive or restore;
- the `from` item of every link you create;
- the parent work of a `contributes_to` link (not an initiative: linking to one needs only read access); in the CLI, `TO@N`.

Items created in the same batch (`$new`) need no entry, and neither does a link you remove. Every entry is checked, even for a record no operation touches. If any has moved on, nothing is applied and the answer names the current revision. A record the batch needs that `expect` leaves out is refused the same way, `basis_changed`, saying it is not in expect (see [Refusals](#refusals)). In the CLI, `ID@N` fills `expect` for you.

## Completing work

Set the status to a completed value and give the evidence: text naming the tests that pass, a commit, a link or a file path, or a file attached or cited in the same write. Anyone in the wirkspace may complete work, except a proposer, who proposes it. In the CLI the evidence is `--evidence`; in MCP and HTTP it is the write's `reason`. The service stores it with who completed the work and shows it at full depth (`Reason (r4): …`); it never judges it.

## Apply or propose

- **Apply** (the default) when a person asked for the change, including completing work.
- **Propose** (`"mode": "propose"` with a `reason`) when you act without a person's direction, as a background or scheduled agent. The write is stored as a proposal for someone whose role may review it, a person or an agent working for one, to [review](/docs/review.md); nothing changes until it is accepted.

## Examples

IDs are illustrative.

### An evidence record linked to two items

```
wirk write new 'Webhook retries: test report, 1 October' \
  --body-file report.md \
  --link related_to:5c1e7a90 \
  --link related_to:8d24f6b1
```

```json
{"request_id": "w-3f9a2c41d0",
 "operations": [
  {"op": "item.create", "ref": "new", "data": {"title": "Webhook retries: test report, 1 October", "body": "…"}},
  {"op": "link.create", "data": {"type": "related_to", "from": "$new", "to": "5c1e7a90"}},
  {"op": "link.create", "data": {"type": "related_to", "from": "$new", "to": "8d24f6b1"}}]}
```

```
Applied w-3f9a2c41d0 · 3 operations
  created 9e4c21f7 ($new) · record — Webhook retries: test report, 1 October
  linked 9e4c21f7 related_to ↔ 5c1e7a90 · link 4d6a0e19
  linked 9e4c21f7 related_to ↔ 8d24f6b1 · link a5c97b32
revisions: 9e4c21f7 r1
next: query 9e4c21f7
```

Use linked records for source material or a substantial report. For work progress and handoffs, send a message on the work item's board with `wirk write message --on ITEM --body-file NOTE`; that keeps the current work brief concise and makes the update visible to people holding the work. See [Messages](/docs/messages.md).

### Completing work, with evidence

```
wirk write edit 5c1e7a90@3 status=completed \
  --evidence 'Fixed in 4f2a9c1; tests/test_webhooks.py::test_retry_backoff passes'
```

```json
{"request_id": "w-8b1d07e2c4", "expect": {"5c1e7a90": 3},
 "reason": "Fixed in 4f2a9c1; tests/test_webhooks.py::test_retry_backoff passes",
 "operations": [{"op": "item.edit", "id": "5c1e7a90", "patch": {"fields": {"status": "completed"}}}]}
```

```
Applied w-8b1d07e2c4 · 1 operation
  edited 5c1e7a90 r3 → r4 · status completed · evidence recorded
revisions: 5c1e7a90 r4
next: query 5c1e7a90
```

`evidence recorded` says the write stored what completing work needs: the reason, a file the edit attaches, or a citation from the item. The receipt does not repeat it; `wirk query 5c1e7a90@4` shows it as `Reason (r4): Fixed in 4f2a9c1; …`.

With a file as the evidence: upload it, then `wirk write edit 5c1e7a90@3 status=completed --upload upload_7e8f9a0b1c2d4e5f8a9b0c1d2e3f4a5b --evidence 'Load test log attached'`.

### A task under an initiative, taken by you

```
wirk write new 'Rate-limit the public API' owner=me \
  --criterion 'Limits are per partner' \
  --criterion 'A 429 names the retry time' \
  --link contributes_to:2f9b3c4e
```

```
Applied w-1c5e9a7b30 · 2 operations
  created 8d24f6b1 ($new) · work — Rate-limit the public API · default status open
  linked 8d24f6b1 contributes_to → 2f9b3c4e · link 9a0b1c2d
revisions: 8d24f6b1 r1
next: query 8d24f6b1
```

`owner=` and `--criterion` make the new item work. Under parent work rather than an initiative, add the parent's revision: `--link contributes_to:2f9b3c4e@7`. `wirk write --help` shows this example with `@7`. To an initiative it is accepted but not needed: it only adds the initiative to `expect`, so the write is refused with `basis_changed` if the initiative changed since you read it.

### Archiving, with a reason

```json
{"reason": "Superseded by 9e4c21f7",
 "expect": {"3b8e1d42": 2},
 "operations": [{"op": "item.archive", "id": "3b8e1d42"}]}
```

### The same completion, proposed by a background job

A job that watches pull requests sees the fix merge. Nobody asked it to act, so it proposes; the reason's first line becomes the proposal's title.

```json
{"request_id": "nightly-20261002-7", "mode": "propose",
 "reason": "Mark webhook retries completed\n\nThe retry fix merged in 4f2a9c1 and the retry tests pass.",
 "expect": {"5c1e7a90": 3},
 "operations": [{"op": "item.edit", "id": "5c1e7a90", "patch": {"fields": {"status": "completed"}}}]}
```

```
Proposed nightly-20261002-7 · 1 operation for review
  proposal c4a1e902 r1 — Mark webhook retries completed
next: query c4a1e902
```

## Receipts

Text receipts list one line per result, then the final revision of every changed item and the one fetch that shows the effect. JSON carries full IDs, and `revisions` is exactly the `expect` map for your next write:

```json
{"ok": true,
 "data": {"request_id": "w-8b1d07e2c4", "write_state": "applied",
          "results": [{"resource": "item", "id": "item_5c1e7a90…", "revision": 4, "operation_index": 0}],
          "defaults": [], "revisions": {"item_5c1e7a90…": 4}},
 "errors": [], "notices": [], "page": {"complete": true, "next_cursor": null}, "timing_ms": {"total": 1.4}}
```

An edit that turns a record into work (by giving it `work`, as `owner=me` does), or work into a record (`"work": null`), says so on its line: `edited 5c1e7a90 r1 → r2 · record → work · status in_progress · owner me`. A status alone cannot make a record work; it is refused with `enum_not_applicable`.

On Pro and Enterprise, receipts may suggest links for you to confirm, each as a runnable line, for example `confirm one: write link 8d24f6b1@1 contributes_to 2f9b3c4e@7`. Only links you write are stored. Writing a context item adds the notice `context_parsed` (the parts found) and, over the authoring target, `context_long`.

## Retries

After an uncertain result (a timeout, a dropped connection), resend the **identical** body with the **same** `request_id`. It applies once, or returns the stored receipt with the notice `replayed` and the line `stored receipt: this retry changed nothing`. The CLI resends once on its own and otherwise prints the command to run. You can also look the receipt up: `wirk query receipt=w-8b1d07e2c4`.

Use the same authenticated person or agent and the same wirkspace: request IDs and receipt lookups are scoped to both. Sending the same request as a different person or agent can create another item. Before handing a write to a different identity, check its outcome through the original identity's receipt or the saved item IDs. A missing receipt under the new identity does not show that the first write failed.

After any refusal, nothing was applied: fix the body and send it under a new request ID. Reusing the old ID for a different body can be refused with `request_conflict`.

## Refusals

Nothing is applied when a write is refused. The text names the operation and the fix:

```
Not applied w-8b1d07e2c4 · basis_changed: 5c1e7a90 is at r5; you read r3
  Re-read it (query 5c1e7a90), then retry under a new request_id with the revision you read.
```

A record missing from `expect`, here the `from` item of a link:

```
Not applied w-8b1d07e2c4 · basis_changed at operations[0] (link.create): 5c1e7a90 is not in expect; it is at r3
  Add the revision you read to expect and send the corrected body under a new request_id, or fetch it first: query 5c1e7a90
```

```
Not applied w-8b1d07e2c4 · reason_required: Completing work needs its evidence: a reason naming the tests that pass, a file path or a link, or a file attached or cited in this write
```

```
Not applied w-6e2d4b8a10 · likely_duplicate at operations[0] (item.create): 71f0c8ae looks like the same work
  Use 71f0c8ae, or rerun with --allow-duplicate-of 71f0c8ae --reason REASON (MCP: "allow_duplicate_of": ["71f0c8ae"] on the item.create, and a "reason")
```

`likely_duplicate` comes only on Pro and Enterprise ([duplicates](/docs/concepts.md#duplicates-and-suggestions)). Others: `not_available` (no readable record has that ID), `ambiguous_ref`, `title_too_long` (titles are at most 200 characters), `quotation_mismatch`, `unknown_owner` (with the members as choices), `unknown_enum_field` and `unknown_enum_option` (with the allowed values), `invalid_context`, `invalid_link`, `requires_review` (context someone else maintains: propose instead), `request_conflict`. All codes: [Errors](/docs/errors.md).

## Files

Upload first, then attach:

```
wirk upload report.pdf --description 'Load test results'
```

```
Uploaded report.pdf · application/pdf · 2.1 MB · sha256 4a5b6c7d
attach with: write new report.pdf --upload upload_7e8f9a0b1c2d4e5f8a9b0c1d2e3f4a5b
```

To attach to an existing item: `wirk write edit ID@N --upload upload_…`, or `"patch": {"attach_uploads": ["upload_…"]}`. Anyone who can edit the item can attach any upload in the wirkspace. How uploads work over HTTP: [HTTP API](/docs/http-api.md#files).
