Pando

REST API reference

The v1 doors, with the exact field vocabulary the server enforces.

Every door lives under https://pando.ink/api/v1. Send a key from Agents and API keys in the app as a bearer token (Authorization: Bearer <key>), and JSON bodies with content-type: application/json. The MCP tools are these same doors with a schema in front; over MCP you get the tools, over HTTP you get this.

Refused, never dropped

The doors refuse rather than guess. A field a door does not take is refused by name, and the refusal teaches the whole vocabulary back: this call does not take noet. It takes parent, after, text, ... A known field sent as the wrong type is refused the same way, and a layout outside the vocabulary is named rather than silently dropped. Nothing is written on any refusal, so a 2xx always means what it says.

POST /api/v1/nodes

Create bullets. One: send parent and text. Many: send parent and items. A batch is checked as a whole before anything lands; the answer carries applied ("all", "partial" or "none"), written, and results per item, so read it rather than assuming. The body takes:

FieldWhat it does
parentThe bullet id to write under. Required for a bearer token: there is no sensible default for where a write into somebody else's notes should land.
afterThe sibling to land after. Left out, the new bullet goes last.
textThe line itself.
noteLonger detail, kept under the line. Sent whole, it replaces the whole note.
layoutHow the bullet is drawn: todo, h1, table, board and the rest of the vocabulary. A word outside it is refused, never dropped.
donetrue ticks a todo.
itemsThe batch form: up to 200 in one call, landing in the order given, checked as a whole first. An item may carry its own items to build a subtree; the cap counts every level.
terseBatch answers carry ids only. terse: false echoes every node back and costs about six times as much.
dryRunRehearse the write: the same kernel checks and the same cap as the real call, nothing stored. One-bullet form only; a batch sending it is refused.

Each entry of items takes:

FieldWhat it does
parentThe bullet id to write under. Required for a bearer token: there is no sensible default for where a write into somebody else's notes should land.
afterThe sibling to land after. Left out, the new bullet goes last.
textThe line itself.
noteLonger detail, kept under the line. Sent whole, it replaces the whole note.
layoutHow the bullet is drawn: todo, h1, table, board and the rest of the vocabulary. A word outside it is refused, never dropped.
donetrue ticks a todo.
itemsThe batch form: up to 200 in one call, landing in the order given, checked as a whole first. An item may carry its own items to build a subtree; the cap counts every level.
parentIdxThe flat spelling of nesting: the index of an earlier item in this batch to sit under. Sending nested items sets it for you.

PATCH /api/v1/nodes/:id

Change one bullet: its text, note, layout, or whether it is done. Four fields exist so a caller never retypes a line to change part of it, and each is a paragraph because each is a different promise:

prependText puts its text in front of the stored line and touches nothing else. It needs no prior read, so it cannot lose an edit the user made while you were thinking.

appendText is the same promise at the other end of the line: what is stored stays byte for byte, your text lands after it.

replace takes {old, new} and changes one part of the line. It is refused unless old occurs in the text exactly once, so it can never fire twice and never silently do nothing.

ifText is the precondition: the exact text you expect the bullet to hold, whitespace included. If the user changed the line while you were composing the write, the door answers conflict and writes nothing.

The body takes:

FieldWhat it does
textThe line itself.
noteLonger detail, kept under the line. Sent whole, it replaces the whole note.
layoutHow the bullet is drawn: todo, h1, table, board and the rest of the vocabulary. A word outside it is refused, never dropped.
donetrue ticks a todo.
prependTextPut this in front of the stored line, leaving the rest byte for byte.
appendTextThe same, at the end of the line.
replaceAn object of old and new: change one part of the line. Refused unless old occurs exactly once.
ifTextThe exact text you expect the bullet to hold. The write is refused with conflict if it changed.
terseBatch answers carry ids only. terse: false echoes every node back and costs about six times as much.

PATCH /api/v1/nodes

The batch form: send items, up to 200, each taking the one-bullet fields plus its own id. Checked as a whole; if any item is refused up front, nothing is written, and the answer says why per item. Each item takes:

FieldWhat it does
idThe bullet to change.
textThe line itself.
noteLonger detail, kept under the line. Sent whole, it replaces the whole note.
layoutHow the bullet is drawn: todo, h1, table, board and the rest of the vocabulary. A word outside it is refused, never dropped.
donetrue ticks a todo.
prependTextPut this in front of the stored line, leaving the rest byte for byte.
appendTextThe same, at the end of the line.
replaceAn object of old and new: change one part of the line. Refused unless old occurs exactly once.
ifTextThe exact text you expect the bullet to hold. The write is refused with conflict if it changed.

DELETE /api/v1/nodes/:id and DELETE /api/v1/nodes

Remove a bullet and everything under it. There is no undo behind this door, so every answer carries removed, how many bullets actually left, and texts, the first few of them. Both forms read the URL only: ?dryRun=true runs the same checks as the real call and deletes nothing, and ?edge= names which placement of a mirrored bullet you mean. The batch form takes ?ids=a,b,c and refuses a mirrored bullet in the list rather than guessing which copy; send that one on its own with edge.

A request body on either form is refused outright. DELETE bodies are never parsed, and an ignored {"dryRun": true} is how a rehearsal becomes a performance.

POST /api/v1/nodes/move

Move bullets under a new parent, everything under each going with it. Send nodes (bullet ids) or edges (placement ids, for mirrored bullets) but not both, plus parent and optionally after. One mutation, all or nothing: the answer names the placement it took for every bullet in moved, and anything it could not resolve comes back in skipped. One bullet moves at POST /api/v1/nodes/:id/move with parent, after and edge.

POST /api/v1/share and POST /api/v1/unshare

Grant somebody a branch, or take it back. share takes node, to (a handle) and level, which is view or edit and nothing else: a word outside those two is refused rather than rounded down, because a caller granting "write" must not be told 200 and mean less than they believed. The grant reaches the bullet and everything under it. unshare takes node and to, and takes effect immediately.

POST /api/v1/remember

Write into your own memory, the branch whoami names as memoryRoot. Without id it creates; with id it changes that memory and creates nothing. The add-only fields exist so a long note is never read back and resent. It takes:

FieldWhat it does
idChange this memory instead of writing a new one. Left out, a new memory is created every time.
textThe memory, in one line. With id this replaces the whole line.
noteLonger detail under the line. Replaces the whole note.
appendNoteAdd this to the end of the note without resending what is there.
prependNoteThe same, at the start of the note.
tagsTags for a new memory, without the #. On an existing one this is refused; use addTags.
addTagsTags to add to the line, skipping any already on it.
removeTagsTags to take off the line, leaving every other byte as it was. Needs id.
parentId to nest under. Defaults to your memory root.
layoutShape the memory in the same call that writes it: a heading, a checklist, a table.

The read doors

GET /api/v1/tree reads a branch as nested items: focus (bullet id), depth (levels below the focus, default 3, max 12), mode (outline, full or markdown), maxNodes, cursor. You have everything when count equals returned and cursor is null.

GET /api/v1/search searches every outline you can reach: q, plus focus, mode, limit, cursor. A bare word matches as a substring of text or note, case and accents ignored; an operator the grammar cannot read refuses the whole query and names it, so a count of 0 is a real 0.

GET /api/v1/changes is what changed since you last looked: root (branch id), since (the cursor from your previous answer, 0 to start), limit. Ask it at the start of a session instead of re-reading the tree; on a measured outline five changed bullets answered in 1,060 bytes where the same tree cost 962,608.