Anytype API v2 — HTTP guide for agents
Local REST API at http://127.0.0.1:31009 (the Anytype app must be
running). Every call sends Authorization: Bearer <key>; keys are created
in the app (Settings → API keys) — the API mints none. Bodies are
compact JSON, and every name this API owns is snake_case — params,
fields and op names alike. (Inside an object's blocks and properties
you are reading the AnyBlock format's content; property and type keys
there are served as slugs by default, as display names under
?keys=name — see the properties bullet below.) Every list takes
?offset=&limit= (default 25, max 1000) and returns
{data, total, offset, limit, has_more}.
First call: GET /v2/auth/whoami. A key may be scoped to particular
spaces and to read-only. grant.scoped: false means the whole account;
otherwise grant.spaces lists what you may touch and grant.permission
whether you may write. Ask this instead of discovering limits through 403s
(space_not_granted / write_not_granted — the message names the grant).
The data model in six ideas
- Spaces contain everything; nearly every route is
/v2/spaces/{space_id}/…. GET /v2/spaces lists them.
- An object =
properties (typed key-values) + blocks (document
content). type is served as a type key (page, task) — never an
id; on input a type NAME ("Meeting note") resolves too.
- Properties are addressed by key — a snake_case slug minted once at
creation and frozen, so it survives renames (
due_date, icon_emoji,
manual_property). GET …/properties spells them that way, and served
documents spell them that way by default; ?keys=name on a read
serves the display-name vocabulary instead ("Due date") — pick slugs
for anything long-lived, names for showing a user their own words.
Input is forgiving everywhere and accepts BOTH: dueDate, DueDate,
due-date, the display name Due date and even Дата выполнения all
resolve to the one property they name; an input matching two properties
is a 400 listing both, and an unknown one never silently creates
anything. (One exception: the compact filter STRING validates before
folding — spell a multi-word name with underscores there, Due_date.)
Select/multi_select values are option names ("In progress",
case-sensitive) — never option ids. A name the property does not already
hold is refused — check it against GET …/properties/{key}/options,
or resend with ?create_missing_options=true to create it (a PATCH caps that
at 64). Unknown property keys are rejected with a did-you-mean.
- Blocks are a FLAT array in pre-order with an integer
indent
(absent = 0) — no children key. Inline formatting is markdown inside
text. Use block ids exactly as a read served them.
- Title and description are not blocks — they live in
properties
(name, description). A fresh object has zero blocks.
- A query is a live query over a type (its type key is still
set, the
name it carries internally); a collection is a hand-curated
list (edited via add_items/remove_items). Chats store messages
outside blocks, paged by order-id cursors.
Which operation
| Intent |
Call |
| find objects |
POST …/{space_id}/search (or POST /v2/search across spaces — rows then carry space_id). Search with filters; don't enumerate GET …/objects |
| read one object |
GET …/objects/{id} — start with ?outline=true |
| change property values |
PATCH op set_properties — add/remove for list values, set for scalars |
| complete a task object |
set_properties ("set":{"done":true} or the status option) — a property, not a block edit |
| change a word/phrase |
op replace_text {find, replace} — id optional; never retype the block |
| toggle a checkbox block |
op update_block {"match":"Draft timeline","set":{"checked":true}} — merge; text untouched. match or id, never both |
| add content |
op insert_blocks with a markdown payload — write markdown, the server parses it |
| restructure |
ops move_block / replace_subtree / delete_block (delete_block takes match too) |
| one table cell |
op set_cell — never rewrite the table |
| show/hide a view column, edit a view |
op update_view — works on queries, collections and a type's default view (PATCH the type OBJECT id from GET …/types/{key}) |
| add / reorder / remove a view |
ops insert_view (copy_from duplicates one) · move_view (position:"first" = default tab) · delete_view |
| create an object |
POST …/objects — shortcut {type, name, properties, markdown} covers most cases |
| delete an object you created |
DELETE …/objects/{id} — archives (Bin, reversible in the app). Only works on objects THIS key created after provenance shipped; anything else → 403 not_created_by_this_key, permanently — don't retry, archive in the app instead. Ownership is matched on the app name EXACTLY (byte-for-byte — re-pair under the identical name to keep delete rights). User content only: system objects 403. Probe first with ?dry_run=true |
| curate a collection |
PATCH ops add_items / remove_items on the collection object |
| read a query / collection |
GET …/queries/{id}/objects · …/collections/{id}/objects (?view=, ?fields=) |
| new type / property |
POST …/types · POST …/properties; select options ride the property, or ?create_missing_options=true mints them from values |
| upload a file |
POST …/files (multipart or {"url":…}) → the id file blocks and chat attachments need |
| download a file or icon |
GET …/files/{file_id}/content; use a file id or a space/member's icon_image. Optional ?width= selects an image size. Supports ranges, conditional reads, and HEAD. |
| chat |
GET/POST …/chats/{id}/messages, POST …/read — see Chats |
Read cheaply
GET …/objects/{id}?outline=true → every block's {indent, id, type}
plus its text truncated to 80 runes — structure + addressable ids at a
fraction of the tokens. Follow up with ?block={id} for one subtree, or
PATCH directly: editing needs no prior full read once you know the
ids (but copy exact text from a full read — outline text may be cut).
- When the request already quotes the text to change, skip the read
entirely:
replace_text {find, replace} locates the block itself, and
update_block/delete_block take match for the same job (one match, or
a refusal listing the candidates).
?include=properties or ?include=blocks reads half the object.
?format=md is a read-only markdown rendering.
- Echo block ids back exactly as a read served them; if one is rejected as
unknown, re-read and use the fresh ids.
?ids=full is the backup/export
shape — the read to archive or clone from, not needed for editing.
- List/search rows are minimal
{id, name, type}; add columns with
fields= (property keys) instead of GETting each object.
- Every object read returns an
etag (envelope + ETag header).
Edit: PATCH ops
PATCH …/objects/{id} body {"ops":[…]} — one atomic batch (≤512 ops,
≤256 blocks per op): any invalid op rejects the whole PATCH with
ops[i]-addressed issues. Fourteen ops:
{ "ops": [
{ "op": "set_properties", "set": {"status": ["Done"]}, "unset": ["oldKey"],
"add": {"tags": ["urgent"]}, "remove": {"assignee": ["bafy…"]} },
{ "op": "update_block", "match": "Draft timeline", "set": {"checked": true} },
{ "op": "replace_text", "find": "Q3 report", "replace": "Q4 report" },
{ "op": "insert_blocks", "after": "b3", "markdown": "## Notes\n- first\n- second" },
{ "op": "move_block", "id": "b9", "inside": "b2", "position": "last" },
{ "op": "delete_block", "id": "b4", "recursive": true },
{ "op": "set_cell", "table_id": "t1", "row": "r2", "col": "c1", "value": "done" },
{ "op": "update_view", "columns": {"status": {"hidden": false}} },
{ "op": "insert_view", "name": "Board", "copy_from": "viewAll1",
"set": {"type": "kanban", "groupBy": "status"} }
] }
set_properties: a key appears in at most one of
set/unset/add/remove. add/remove are per-entry list edits
(select/multiSelect/objects/files) — appending one tag never rewrites
the array. remove never creates the option it names. set: {"k": []}
= present-but-empty; unset removes presence.
update_block is THE block-field op (merge; explicit null clears a
field) — checkbox, color, language, retype, or full text rewrite.
match addresses the block by its TEXT on update_block and
delete_block — the id alternative: give one or the other, never
both (and never neither). The text must appear in exactly ONE block or
the op refuses: zero → read the outline, several → the error lists
candidate ids to retry with. Repeats inside the one matched block are
fine — match names a block, not an occurrence. It reads the document as
the ops before it in the batch left it.
replace_text: id is optional — omitted, find locates the block
and must appear in exactly ONE block (zero or several matching blocks
refuse; the ambiguity error lists candidate ids to retry with). Within
the matched block find must match exactly once ("found 2 matches —
provide more context"); replace_all: true is the escape, within that
one block only. Preferred over update_block for word-level edits.
replace_subtree {id, blocks} swaps a block plus descendants.
insert_blocks: blocks (flat array) or markdown — mutually
exclusive, same targeting. Target with one of after/before/inside
(+position: first|last inside that container); omit all three and
position picks an end of the DOCUMENT — last (or absent) appends,
first inserts at the start, both on an empty object too. Payload
indent: 0 = the anchor's level (after/before) or the container's
child level (inside). move_block targets the same way, so
{"op":"move_block","id":"b9","position":"first"} moves a block to the top
of the document.
- Author new content without ids — an
id names an EXISTING block, so
insert_blocks takes none anywhere in its payload (rows and columns
included); the server mints them and returns them in created_blocks,
keyed by the payload path that produced each — ops[0].blocks[0],
ops[0].blocks[0].rows[1], ops[0].blocks[0].columns[0]. The same holds
wherever you leave an id out of an existing-content payload (a new row in
update_block set.rows, a block inside a set_cell array), so you never
have to re-read to learn an id you just created.
update_view edits ONE dataview view — never resend the views array.
block/view are optional when the object has one dataview and it one
view (types, queries, collections usually do). set merges view fields
(name, type, groupBy, sorts, filters — arrays replace whole;
filter takes the compact string; null clears a field); columns merges
per property key: {"hidden": false} shows a column, null removes it,
a new key appends one. Works on Blocks-restricted objects — view config
is not a block edit.
insert_view/move_view/delete_view complete the family (same
addressing, same channels; insert_view's name is its own required field —
not in set). insert_view needs only name — bare default: every listed
property visible, newest first; copy_from duplicates a view (then
set/columns override); the minted id returns in created_views,
keyed ops[i]. move_view REQUIRES one of after/before/position
("first" = default tab). delete_view refuses the last view — insert the
replacement first (one atomic batch swaps a bad default view).
- Response: new
etag, created_blocks (payload position → real id;
nested row/column/cell slots included), created_views (same, for minted
view ids), created (options minted under ?create_missing_options=true),
diff_stats {blocks_added, blocks_removed, blocks_changed, blocks_moved, properties_changed}, warnings (advisory, e.g. an unguarded date filter).
- There is no whole-document replace — never read a document,
regenerate it and write it back. Replace a section with
replace_subtree; start over by batching delete_blocks with the new
insert_blocks.
Query
POST …/search body: {query?, type?, filter?|filters?, sorts?, fields?}.
Pagination is the query params — a body limit is rejected. Search is a
read: no Idempotency-Key, dry_run ignored.
{ "query": "report", "type": "task",
"filter": "done = false AND (due_date < currentWeek() OR due_date IS EMPTY)",
"sorts": [ { "property": "due_date", "direction": "asc" } ],
"fields": ["name", "due_date", "status"] }
- Prefer the compact
filter string (≤4096 chars):
status IN ("In progress", "Blocked") · name CONTAINS "report" ·
last_modified_date > daysAgo(7) · tags HAS ALL ("urgent", "q3") AND assignee IS NOT EMPTY. Dates are RFC 3339 or preset functions
(today(), currentWeek(), daysAgo(n)). Parse errors are
offset-addressed with did-you-mean.
- The structured
filters array: leaf =
{"property","condition","value"}, group =
{"operator":"and|or","filters":[…]} (non-empty). Date values there are
unix seconds, not RFC 3339 (the string form converts for you).
filter and filters together → 400 ambiguous_input.
type is also a filter pseudo-key for multi-type: type IN ("task", "bug"). File rows appear only when a file type is named in the type
channel (type = "image", type IN (… "file")) — size > 5 alone
matches nothing; compose type = "image" AND size > 5. mimeType and
size work in fields/filters/sorts.
- An unguarded
due_date < … also matches objects with no date — the
response warns; add AND due_date IS NOT EMPTY unless intended.
- Full-text
total is a lower bound while has_more is true — walk
pages, don't plan on the number.
- Sorts: any property key,
{"property", "direction": "asc|desc"};
default is last_modified_date desc.
Chats
GET …/chats/{id}/messages returns {messages, state, message_count, has_more, next_before?, next_after?}. state carries unread_messages,
unread_mentions, last_state_id — so "anything new?" is a ?limit=1
read. Cursors only (?after= walks forward; otherwise newest-first via
next_before); ?offset= is rejected.
- Message
text is inline markup both ways (mentions as
<mention objectId="…">); ≤8000 chars; attachments = up to 32 object
ids from POST …/files. ?reactions=full adds who reacted.
- Mark read:
POST …/chats/{id}/read with {"up_to": <order>, "last_state_id": <id>} — both from the same GET, else nothing marks.
PATCH …/messages/{id} {"text"} edits text only (attachments kept);
editing/deleting another member's message → 403. DELETE permanently
removes orphaned attachments — the response warns with their ids.
- No etag/If-Match on chats; order ids are the concurrency vocabulary.
Conventions on every call
- Errors are
{status, code, message, issues:[{path, message, hint}]} — built to be repaired in ONE retry: fix the named path per the
hint, resend once. Never loop blindly; 403s and validation failures do
not improve with repetition.
warnings on success responses are advisory — no retry needed.
Idempotency-Key (all mutations incl. DELETE): mint a fresh random
key per logical mutation; reuse the SAME key only to retry the identical
request — a replay answers Idempotency-Replayed: true. The same key
with a different body/path/query → 409 idempotency_conflict.
?dry_run=true on any mutation: full validation, identical verdicts,
nothing committed (response echoes dry_run: true).
?create_missing_options=true on any write that sets a select value: consent
to MINT option names the property does not hold yet. Default off, and off
refuses — an unmatched name is usually a typo or a stale label, and a
minted option joins the property's vocabulary for the whole space with no
delete surface. created on the response lists what a consented write
actually minted.
If-Match (objects only): send an etag back verbatim when a
concurrent overwrite would matter; mismatch → 409 etag_mismatch
carrying the current etag. Omit it by default — sync also moves the
etag, so habitual If-Match 409s on noise.
POST /v2/validate pre-flights an AnyBlock document: 200 with
{issues, warnings} even for an invalid one.
Look it up at runtime — don't guess
GET /v2/schemas — index. GET /v2/schemas/{kind} — strict JSON Schema
- worked example per request kind (
object, shortcut, type,
template, property, query, collection, file, search, space,
filters, chat, chatMessage, chatMessageEdit, chatReaction,
chatRead). The filters kind also serves the filter-string grammar
(EBNF + examples). GET /v2/schemas/ops/{op} — per-op schema + example.
- Live vocabulary:
GET …/types and GET …/types/{key} (the type
document, incl. its property keys); GET …/properties;
GET …/properties/{key}/options?prefix= (check before writing select
values); GET …/members and …/members/me (participant ids for
assignee/creator; /me is your own).
- Full reference:
GET /v2/docs/openapi.json (or .yaml).
Mistakes that actually happen
- RFC 3339 dates in the structured
filters array (unix seconds
there — or use the filter string, which converts).
- Rewriting a whole multiSelect array to add one entry — use
set_properties.add.
- Option ids, or wrong-case option names, as values — names are the
identity; check
…/options first.
- Reusing one
Idempotency-Key across different requests → 409. Keys are
per logical mutation, not per session.
replace_text/edit text is markup SOURCE: *, [, ~~ in a
replacement become real formatting — escape with \.
- Deleting a parent block without
recursive: true, or moving a block
into its own subtree — rejected with the reason; read the error.
- Filtering on file metadata without naming a file type — zero rows.
Not in v2 (yet)
- Object DELETE is own-output-only —
DELETE …/objects/{id} archives
only objects THIS key created (recorded at creation, immutably). Objects
created before that shipped, in the app, by import or by other members
are permanently 403 for every key — there is no "delete arbitrary
objects" capability. ?dry_run=true is the cheap deletability probe;
types/properties still use their own DELETE routes.
- No file content extraction ("read this PDF") in the API. Download its
bytes with
GET …/files/{file_id}/content and process them in the client.
- No chat SSE stream under /v2 and no per-chat message full-text
search (both v1 for now); poll
GET messages?limit=1 instead.
GET …/types/{key}/schema is a 501 stub — compose from
GET …/types/{key} + …/properties/{key}/options.
- No option rename/recolor/delete under /v2 (v1 tags admin, Phase 8).
1---2name: anytype-api3description: Call Anytype's local HTTP API v2 directly — search, read, create and edit objects, queries, collections and chats over REST. Use when writing scripts, SDK code or curl against the API. For interactive note/task work prefer the `anytype` CLI and its skill (cmd/anytype/SKILL.md); this guide is the raw-HTTP layer beneath it.4---56# Anytype API v2 — HTTP guide for agents78Local REST API at `http://127.0.0.1:31009` (the Anytype app must be9running). Every call sends `Authorization: Bearer <key>`; keys are created10in the app (Settings → API keys) — **the API mints none**. Bodies are11compact JSON, and every name this API owns is `snake_case` — params,12fields and op names alike. (Inside an object's `blocks` and `properties`13you are reading the AnyBlock format's content; property and type keys14there are served as slugs by default, as display names under15`?keys=name` — see the properties bullet below.) Every list takes16`?offset=&limit=` (default 25, max 1000) and returns17`{data, total, offset, limit, has_more}`.1819**First call: `GET /v2/auth/whoami`.** A key may be scoped to particular20spaces and to read-only. `grant.scoped: false` means the whole account;21otherwise `grant.spaces` lists what you may touch and `grant.permission`22whether you may write. Ask this instead of discovering limits through 403s23(`space_not_granted` / `write_not_granted` — the message names the grant).2425## The data model in six ideas2627- **Spaces** contain everything; nearly every route is28 `/v2/spaces/{space_id}/…`. `GET /v2/spaces` lists them.29- An **object** = `properties` (typed key-values) + `blocks` (document30 content). `type` is served as a type **key** (`page`, `task`) — never an31 id; on input a type NAME (`"Meeting note"`) resolves too.32- **Properties** are addressed by key — a snake_case slug minted once at33 creation and frozen, so it survives renames (`due_date`, `icon_emoji`,34 `manual_property`). `GET …/properties` spells them that way, and served35 documents spell them that way **by default**; `?keys=name` on a read36 serves the display-name vocabulary instead (`"Due date"`) — pick slugs37 for anything long-lived, names for showing a user their own words.38 Input is forgiving everywhere and accepts BOTH: `dueDate`, `DueDate`,39 `due-date`, the display name `Due date` and even `Дата выполнения` all40 resolve to the one property they name; an input matching two properties41 is a 400 listing both, and an unknown one never silently creates42 anything. (One exception: the compact `filter` STRING validates before43 folding — spell a multi-word name with underscores there, `Due_date`.)44 Select/multi_select values are option **names** (`"In progress"`,45 case-sensitive) — never option ids. A name the property does not already46 hold is **refused** — check it against `GET …/properties/{key}/options`,47 or resend with **`?create_missing_options=true`** to create it (a PATCH caps that48 at 64). Unknown property keys are rejected with a did-you-mean.49- **Blocks** are a FLAT array in pre-order with an integer `indent`50 (absent = 0) — no `children` key. Inline formatting is markdown inside51 `text`. Use block ids exactly as a read served them.52- Title and description are **not blocks** — they live in `properties`53 (`name`, `description`). A fresh object has zero blocks.54- A **query** is a live query over a type (its type key is still `set`, the55 name it carries internally); a **collection** is a hand-curated56 list (edited via `add_items`/`remove_items`). **Chats** store messages57 outside blocks, paged by order-id cursors.5859## Which operation6061| Intent | Call |62|---|---|63| find objects | `POST …/{space_id}/search` (or `POST /v2/search` across spaces — rows then carry `space_id`). Search with filters; don't enumerate `GET …/objects` |64| read one object | `GET …/objects/{id}` — start with `?outline=true` |65| change property values | PATCH op `set_properties` — `add`/`remove` for list values, `set` for scalars |66| complete a task object | `set_properties` (`"set":{"done":true}` or the status option) — a property, not a block edit |67| change a word/phrase | op `replace_text` `{find, replace}` — `id` optional; never retype the block |68| toggle a checkbox block | op `update_block` `{"match":"Draft timeline","set":{"checked":true}}` — merge; text untouched. `match` or `id`, never both |69| add content | op `insert_blocks` with a `markdown` payload — write markdown, the server parses it |70| restructure | ops `move_block` / `replace_subtree` / `delete_block` (`delete_block` takes `match` too) |71| one table cell | op `set_cell` — never rewrite the table |72| show/hide a view column, edit a view | op `update_view` — works on queries, collections and a type's default view (PATCH the type OBJECT id from `GET …/types/{key}`) |73| add / reorder / remove a view | ops `insert_view` (`copy_from` duplicates one) · `move_view` (`position:"first"` = default tab) · `delete_view` |74| create an object | `POST …/objects` — shortcut `{type, name, properties, markdown}` covers most cases |75| delete an object you created | `DELETE …/objects/{id}` — archives (Bin, reversible in the app). Only works on objects THIS key created after provenance shipped; anything else → 403 `not_created_by_this_key`, permanently — don't retry, archive in the app instead. Ownership is matched on the app name EXACTLY (byte-for-byte — re-pair under the identical name to keep delete rights). User content only: system objects 403. Probe first with `?dry_run=true` |76| curate a collection | PATCH ops `add_items` / `remove_items` on the collection object |77| read a query / collection | `GET …/queries/{id}/objects` · `…/collections/{id}/objects` (`?view=`, `?fields=`) |78| new type / property | `POST …/types` · `POST …/properties`; select options ride the property, or `?create_missing_options=true` mints them from values |79| upload a file | `POST …/files` (multipart or `{"url":…}`) → the id file blocks and chat attachments need |80| download a file or icon | `GET …/files/{file_id}/content`; use a file id or a space/member's `icon_image`. Optional `?width=` selects an image size. Supports ranges, conditional reads, and `HEAD`. |81| chat | `GET/POST …/chats/{id}/messages`, `POST …/read` — see Chats |8283## Read cheaply8485- `GET …/objects/{id}?outline=true` → every block's `{indent, id, type}`86 plus its text truncated to 80 runes — structure + addressable ids at a87 fraction of the tokens. Follow up with `?block={id}` for one subtree, or88 PATCH directly: **editing needs no prior full read once you know the89 ids** (but copy exact text from a full read — outline text may be cut).90- When the request already quotes the text to change, skip the read91 entirely: `replace_text {find, replace}` locates the block itself, and92 `update_block`/`delete_block` take `match` for the same job (one match, or93 a refusal listing the candidates).94- `?include=properties` or `?include=blocks` reads half the object.95 `?format=md` is a read-only markdown rendering.96- Echo block ids back exactly as a read served them; if one is rejected as97 unknown, re-read and use the fresh ids. `?ids=full` is the backup/export98 shape — the read to archive or clone from, not needed for editing.99- List/search rows are minimal `{id, name, type}`; add columns with100 `fields=` (property keys) instead of GETting each object.101- Every object read returns an `etag` (envelope + `ETag` header).102103## Edit: PATCH ops104105`PATCH …/objects/{id}` body `{"ops":[…]}` — one atomic batch (≤512 ops,106≤256 blocks per op): any invalid op rejects the whole PATCH with107`ops[i]`-addressed issues. Fourteen ops:108109```json110{ "ops": [111 { "op": "set_properties", "set": {"status": ["Done"]}, "unset": ["oldKey"],112 "add": {"tags": ["urgent"]}, "remove": {"assignee": ["bafy…"]} },113 { "op": "update_block", "match": "Draft timeline", "set": {"checked": true} },114 { "op": "replace_text", "find": "Q3 report", "replace": "Q4 report" },115 { "op": "insert_blocks", "after": "b3", "markdown": "## Notes\n- first\n- second" },116 { "op": "move_block", "id": "b9", "inside": "b2", "position": "last" },117 { "op": "delete_block", "id": "b4", "recursive": true },118 { "op": "set_cell", "table_id": "t1", "row": "r2", "col": "c1", "value": "done" },119 { "op": "update_view", "columns": {"status": {"hidden": false}} },120 { "op": "insert_view", "name": "Board", "copy_from": "viewAll1",121 "set": {"type": "kanban", "groupBy": "status"} }122] }123```124125- **`set_properties`**: a key appears in at most one of126 `set`/`unset`/`add`/`remove`. `add`/`remove` are per-entry list edits127 (select/multiSelect/objects/files) — appending one tag never rewrites128 the array. `remove` never creates the option it names. `set: {"k": []}`129 = present-but-empty; `unset` removes presence.130- **`update_block`** is THE block-field op (merge; explicit `null` clears a131 field) — checkbox, color, language, retype, or full text rewrite.132- **`match` addresses the block by its TEXT** on `update_block` and133 `delete_block` — the `id` alternative: give one or the other, **never134 both** (and never neither). The text must appear in exactly ONE block or135 the op refuses: zero → read the outline, several → the error lists136 candidate ids to retry with. Repeats inside the one matched block are137 fine — `match` names a block, not an occurrence. It reads the document as138 the ops before it in the batch left it.139- **`replace_text`**: `id` is optional — omitted, `find` locates the block140 and must appear in exactly ONE block (zero or several matching blocks141 refuse; the ambiguity error lists candidate ids to retry with). Within142 the matched block `find` must match exactly once ("found 2 matches —143 provide more context"); `replace_all: true` is the escape, within that144 one block only. Preferred over `update_block` for word-level edits.145 `replace_subtree {id, blocks}` swaps a block plus descendants.146- **`insert_blocks`**: `blocks` (flat array) or `markdown` — mutually147 exclusive, same targeting. Target with one of `after`/`before`/`inside`148 (+`position: first|last` inside that container); omit all three and149 `position` picks an end of the DOCUMENT — `last` (or absent) appends,150 `first` inserts at the start, both on an empty object too. Payload151 `indent: 0` = the anchor's level (`after`/`before`) or the container's152 child level (`inside`). `move_block` targets the same way, so153 `{"op":"move_block","id":"b9","position":"first"}` moves a block to the top154 of the document.155- **Author new content without ids** — an `id` names an EXISTING block, so156 `insert_blocks` takes none anywhere in its payload (rows and columns157 included); the server mints them and returns them in `created_blocks`,158 keyed by the payload path that produced each — `ops[0].blocks[0]`,159 `ops[0].blocks[0].rows[1]`, `ops[0].blocks[0].columns[0]`. The same holds160 wherever you leave an id out of an existing-content payload (a new row in161 `update_block set.rows`, a block inside a `set_cell` array), so you never162 have to re-read to learn an id you just created.163- **`update_view`** edits ONE dataview view — never resend the views array.164 `block`/`view` are optional when the object has one dataview and it one165 view (types, queries, collections usually do). `set` merges view fields166 (`name`, `type`, `groupBy`, `sorts`, `filters` — arrays replace whole;167 `filter` takes the compact string; null clears a field); `columns` merges168 per property key: `{"hidden": false}` shows a column, `null` removes it,169 a new key appends one. Works on Blocks-restricted objects — view config170 is not a block edit.171- **`insert_view`/`move_view`/`delete_view`** complete the family (same172 addressing, same channels; insert_view's name is its own required field —173 not in `set`). insert_view needs only `name` — bare default: every listed174 property visible, newest first; `copy_from` duplicates a view (then175 `set`/`columns` override); the minted id returns in `created_views`,176 keyed `ops[i]`. move_view REQUIRES one of `after`/`before`/`position`177 (`"first"` = default tab). delete_view refuses the last view — insert the178 replacement first (one atomic batch swaps a bad default view).179- Response: new `etag`, `created_blocks` (payload position → real id;180 nested row/column/cell slots included), `created_views` (same, for minted181 view ids), `created` (options minted under `?create_missing_options=true`),182 `diff_stats {blocks_added, blocks_removed, blocks_changed, blocks_moved,183 properties_changed}`, `warnings` (advisory, e.g. an unguarded date filter).184- **There is no whole-document replace** — never read a document,185 regenerate it and write it back. Replace a section with186 `replace_subtree`; start over by batching `delete_block`s with the new187 `insert_blocks`.188189## Query190191`POST …/search` body: `{query?, type?, filter?|filters?, sorts?, fields?}`.192Pagination is the query params — a body `limit` is rejected. Search is a193read: no `Idempotency-Key`, `dry_run` ignored.194195```json196{ "query": "report", "type": "task",197 "filter": "done = false AND (due_date < currentWeek() OR due_date IS EMPTY)",198 "sorts": [ { "property": "due_date", "direction": "asc" } ],199 "fields": ["name", "due_date", "status"] }200```201202- **Prefer the compact `filter` string** (≤4096 chars):203 `status IN ("In progress", "Blocked")` · `name CONTAINS "report"` ·204 `last_modified_date > daysAgo(7)` · `tags HAS ALL ("urgent", "q3") AND205 assignee IS NOT EMPTY`. Dates are RFC 3339 or preset functions206 (`today()`, `currentWeek()`, `daysAgo(n)`). Parse errors are207 offset-addressed with did-you-mean.208- The structured `filters` array: leaf =209 `{"property","condition","value"}`, group =210 `{"operator":"and|or","filters":[…]}` (non-empty). Date values there are211 **unix seconds**, not RFC 3339 (the string form converts for you).212 `filter` and `filters` together → 400 `ambiguous_input`.213- `type` is also a filter pseudo-key for multi-type: `type IN ("task",214 "bug")`. **File rows appear only when a file type is named** in the type215 channel (`type = "image"`, `type IN (… "file")`) — `size > 5` alone216 matches nothing; compose `type = "image" AND size > 5`. `mimeType` and217 `size` work in fields/filters/sorts.218- An unguarded `due_date < …` also matches objects with **no** date — the219 response warns; add `AND due_date IS NOT EMPTY` unless intended.220- Full-text `total` is a lower bound while `has_more` is true — walk221 pages, don't plan on the number.222- Sorts: any property key, `{"property", "direction": "asc|desc"}`;223 default is `last_modified_date desc`.224225## Chats226227- `GET …/chats/{id}/messages` returns `{messages, state, message_count,228 has_more, next_before?, next_after?}`. `state` carries `unread_messages`,229 `unread_mentions`, `last_state_id` — so "anything new?" is a `?limit=1`230 read. Cursors only (`?after=` walks forward; otherwise newest-first via231 `next_before`); `?offset=` is rejected.232- Message `text` is inline markup both ways (mentions as233 `<mention objectId="…">`); ≤8000 chars; `attachments` = up to 32 object234 ids from `POST …/files`. `?reactions=full` adds who reacted.235- Mark read: `POST …/chats/{id}/read` with `{"up_to": <order>,236 "last_state_id": <id>}` — **both** from the same GET, else nothing marks.237- `PATCH …/messages/{id}` `{"text"}` edits text only (attachments kept);238 editing/deleting another member's message → 403. DELETE permanently239 removes orphaned attachments — the response warns with their ids.240- No etag/If-Match on chats; order ids are the concurrency vocabulary.241242## Conventions on every call243244- **Errors** are `{status, code, message, issues:[{path, message,245 hint}]}` — built to be repaired in ONE retry: fix the named path per the246 hint, resend once. Never loop blindly; 403s and validation failures do247 not improve with repetition.248- `warnings` on success responses are advisory — no retry needed.249- **`Idempotency-Key`** (all mutations incl. DELETE): mint a fresh random250 key per logical mutation; reuse the SAME key only to retry the identical251 request — a replay answers `Idempotency-Replayed: true`. The same key252 with a different body/path/query → 409 `idempotency_conflict`.253- **`?dry_run=true`** on any mutation: full validation, identical verdicts,254 nothing committed (response echoes `dry_run: true`).255- **`?create_missing_options=true`** on any write that sets a select value: consent256 to MINT option names the property does not hold yet. Default off, and off257 refuses — an unmatched name is usually a typo or a stale label, and a258 minted option joins the property's vocabulary for the whole space with no259 delete surface. `created` on the response lists what a consented write260 actually minted.261- **`If-Match`** (objects only): send an etag back verbatim when a262 concurrent overwrite would matter; mismatch → 409 `etag_mismatch`263 carrying the current etag. Omit it by default — sync also moves the264 etag, so habitual If-Match 409s on noise.265- `POST /v2/validate` pre-flights an AnyBlock document: 200 with266 `{issues, warnings}` even for an invalid one.267268## Look it up at runtime — don't guess269270- `GET /v2/schemas` — index. `GET /v2/schemas/{kind}` — strict JSON Schema271 + worked example per request kind (`object`, `shortcut`, `type`,272 `template`, `property`, `query`, `collection`, `file`, `search`, `space`,273 `filters`, `chat`, `chatMessage`, `chatMessageEdit`, `chatReaction`,274 `chatRead`). The `filters` kind also serves the filter-string grammar275 (EBNF + examples). `GET /v2/schemas/ops/{op}` — per-op schema + example.276- Live vocabulary: `GET …/types` and `GET …/types/{key}` (the type277 document, incl. its property keys); `GET …/properties`;278 `GET …/properties/{key}/options?prefix=` (check before writing select279 values); `GET …/members` and `…/members/me` (participant ids for280 `assignee`/`creator`; `/me` is your own).281- Full reference: `GET /v2/docs/openapi.json` (or `.yaml`).282283## Mistakes that actually happen284285- RFC 3339 dates in the **structured** `filters` array (unix seconds286 there — or use the filter string, which converts).287- Rewriting a whole multiSelect array to add one entry — use288 `set_properties.add`.289- Option **ids**, or wrong-case option names, as values — names are the290 identity; check `…/options` first.291- Reusing one `Idempotency-Key` across different requests → 409. Keys are292 per logical mutation, not per session.293- `replace_text`/`edit` text is markup SOURCE: `*`, `[`, `~~` in a294 replacement become real formatting — escape with `\`.295- Deleting a parent block without `recursive: true`, or moving a block296 into its own subtree — rejected with the reason; read the error.297- Filtering on file metadata without naming a file type — zero rows.298299## Not in v2 (yet)300301- **Object DELETE is own-output-only** — `DELETE …/objects/{id}` archives302 only objects THIS key created (recorded at creation, immutably). Objects303 created before that shipped, in the app, by import or by other members304 are permanently 403 for every key — there is no "delete arbitrary305 objects" capability. `?dry_run=true` is the cheap deletability probe;306 types/properties still use their own DELETE routes.307- No file content extraction ("read this PDF") in the API. Download its308 bytes with `GET …/files/{file_id}/content` and process them in the client.309- **No chat SSE stream** under /v2 and no per-chat message full-text310 search (both v1 for now); poll `GET messages?limit=1` instead.311- `GET …/types/{key}/schema` is a 501 stub — compose from312 `GET …/types/{key}` + `…/properties/{key}/options`.313- No option rename/recolor/delete under /v2 (v1 tags admin, Phase 8).