# write

> **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

`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

| Operation | Shape |
|---|---|
| `item.create` | `{"op": "item.create", "ref": "new", "data": ItemData, "allow_duplicate_of": ["ID"]}` (`ref` and `allow_duplicate_of` optional) |
| `item.edit` | `{"op": "item.edit", "id": "ID", "patch": ItemPatch}` |
| `item.archive` | `{"op": "item.archive", "id": "ID"}`, with the write's `reason` |
| `item.restore` | `{"op": "item.restore", "id": "ID"}` |
| `link.create` | `{"op": "link.create", "data": {"type": "…", "from": "ID", "to": "ID", …}}` |
| `link.remove` | `{"op": "link.remove", "id": "LINK_ID"}` |

**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.
- `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 `doc`, 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"`.

## Links

`link.create` data by type:

| Type | Data |
|---|---|
| `related_to` | `{"type": "related_to", "from": A, "to": B}` |
| `contributes_to` | `{"type": "contributes_to", "from": CHILD, "to": PARENT, "contribution": "…", "criteria": [{"text": "…"}]}`. `contribution` and `criteria` are optional; an initiative takes `contribution` only |
| `requires` | `{"type": "requires", "from": WORK, "to": PREREQUISITE, "criterion_id": "…"}` (`criterion_id` optional; work to work only) |
| `cites` | `{"type": "cites", "from": A, "to": EVIDENCE, "target_revision": 2, "selector": Selector, "relation": "supports" \| "contradicts" \| "background", "quotation": "…"}` (`quotation` optional) |
| `relies_on` | `{"type": "relies_on", "from": A, "to": SOURCE, "target_revision": 3, "assumption": "one line"}` |

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

| Selector | Shape |
|---|---|
| The whole body | `{"type": "whole", "part": {"type": "body"}}` |
| A whole file | `{"type": "whole", "part": {"type": "file", "file_id": "…"}}` |
| A stretch of text | `{"type": "text", "part": {"type": "body"}, "start": 120, "end": 310}` |
| Lines | `{"type": "lines", "part": {"type": "body"}, "first": 4, "last": 9}` |
| Pages of a file | `{"type": "pages", "file_id": "…", "first": 2, "last": 3}` |
| A stretch of audio or video | `{"type": "time", "file_id": "…", "start_ms": 61000, "end_ms": 95000}` |
| A region of an image | `{"type": "region", "file_id": "…", "x": 0.1, "y": 0.2, "width": 0.5, "height": 0.3}` |

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

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. 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. 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 a person to [review](/docs/review.md); nothing changes until it is accepted. Nobody accepts their own proposal.

## Examples

IDs are illustrative.

### A progress doc linked to two items

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

```json
{"request_id": "w-3f9a2c41d0",
 "operations": [
  {"op": "item.create", "ref": "new", "data": {"title": "Webhook retries: progress, 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) · doc — Webhook retries: progress, 1 October
  linked 9e4c21f7 related_to ↔ 5c1e7a90 · link 4d6a0e19
  linked 9e4c21f7 related_to ↔ 8d24f6b1 · link a5c97b32
revisions: 9e4c21f7 r1
next: query 9e4c21f7
```

A linked doc is the cheap way to record progress; prefer it to rewriting an item's body.

### 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
next: query 5c1e7a90
```

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

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

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 prints the exact retry otherwise. You can also look the receipt up: `wirk query receipt=w-8b1d07e2c4`.

## 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
  Fetch it again (query 5c1e7a90), check that your change still applies, and resend under a new request_id.
```

```
Not applied w-8b1d07e2c4 · reason_required: Completing work needs its evidence: a note 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")
```

Others: `not_available` (no readable record has that ID), `ambiguous_ref`, `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).
