REST API reference
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:
| Field | What it does |
|---|---|
parent | The 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. |
after | The sibling to land after. Left out, the new bullet goes last. |
text | The line itself. |
note | Longer detail, kept under the line. Sent whole, it replaces the whole note. |
layout | How the bullet is drawn: todo, h1, table, board and the rest of the vocabulary. A word outside it is refused, never dropped. |
done | true ticks a todo. |
items | The 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. |
terse | Batch answers carry ids only. terse: false echoes every node back and costs 2.7 times as much, measured on 11 edits. |
dryRun | Rehearse 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:
| Field | What it does |
|---|---|
parent | The 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. |
after | The sibling to land after. Left out, the new bullet goes last. |
text | The line itself. |
note | Longer detail, kept under the line. Sent whole, it replaces the whole note. |
layout | How the bullet is drawn: todo, h1, table, board and the rest of the vocabulary. A word outside it is refused, never dropped. |
done | true ticks a todo. |
items | The 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. |
parentIdx | The 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:
| Field | What it does |
|---|---|
text | The line itself. |
note | Longer detail, kept under the line. Sent whole, it replaces the whole note. |
layout | How the bullet is drawn: todo, h1, table, board and the rest of the vocabulary. A word outside it is refused, never dropped. |
done | true ticks a todo. |
prependText | Put this in front of the stored line, leaving the rest byte for byte. |
appendText | The same, at the end of the line. |
prependNote | Put this at the START of the note, joined to what is there with one newline. The note is not resent, so nothing in it can be dropped. On a bullet with no note it simply becomes the note. |
appendNote | The same, at the end of the note. Use it rather than reading a note back and sending it whole: that is where a line gets lost. |
replace | An object of old and new: change one part of the line. Refused unless old occurs exactly once. |
ifText | The exact text you expect the bullet to hold. The write is refused with conflict if it changed. |
addTags | Tags to put at the end of the line, without the #. A set: one already there is not written twice, and every other byte of the line is left alone. Works alongside text, replace or appendText in the same call. |
removeTags | Tags to take off the line, leaving every other byte as it was. Write @name to remove an @mention rather than a #tag. |
terse | Batch answers carry ids only. terse: false echoes every node back and costs 2.7 times as much, measured on 11 edits. |
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:
| Field | What it does |
|---|---|
id | The bullet to change. |
text | The line itself. |
note | Longer detail, kept under the line. Sent whole, it replaces the whole note. |
layout | How the bullet is drawn: todo, h1, table, board and the rest of the vocabulary. A word outside it is refused, never dropped. |
done | true ticks a todo. |
prependText | Put this in front of the stored line, leaving the rest byte for byte. |
appendText | The same, at the end of the line. |
prependNote | Put this at the START of the note, joined to what is there with one newline. The note is not resent, so nothing in it can be dropped. On a bullet with no note it simply becomes the note. |
appendNote | The same, at the end of the note. Use it rather than reading a note back and sending it whole: that is where a line gets lost. |
replace | An object of old and new: change one part of the line. Refused unless old occurs exactly once. |
ifText | The exact text you expect the bullet to hold. The write is refused with conflict if it changed. |
addTags | Tags to put at the end of the line, without the #. A set: one already there is not written twice, and every other byte of the line is left alone. Works alongside text, replace or appendText in the same call. |
removeTags | Tags to take off the line, leaving every other byte as it was. Write @name to remove an @mention rather than a #tag. |
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, from a signed-in session. A key is refused here (it may only leave a branch it was lent), and no page of the app calls these doors. To hold an agent to one branch, open that branch, open Agents and API keys in Settings, tap the agent's row under Your agents, and press Let it reach only “…”, the bullet you are in: it then reads and writes there, remembers there, and reaches nothing else in your outline. The door behind that press is PUT /api/v1/agents/:id/keep with node, from a signed-in session. 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 the agent's memory, the bullet whoami names as memoryRoot. That bullet is one of the person's, assigned under Agents and API keys or on the consent page; with none assigned the route answers 404 no_memory and writes nowhere. 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:
| Field | What it does |
|---|---|
id | Change this memory instead of writing a new one. Left out, a new memory is created every time. |
text | The memory, in one line. With id this replaces the whole line. |
note | Longer detail under the line. Replaces the whole note. |
appendNote | Add this to the end of the note without resending what is there. |
prependNote | The same, at the start of the note. |
tags | Tags for a new memory, without the #. On an existing one this is refused; use addTags. |
addTags | Tags to add to the line, skipping any already on it. |
removeTags | Tags to take off the line, leaving every other byte as it was. Needs id. |
parent | Id to nest under. Defaults to your memory root. |
layout | Shape the memory in the same call that writes it: a heading, a checklist, a table. |
The one-job doors
Each of these takes a handful of fields and refuses the rest by name, exactly like the doors above. The vocabulary below is the array the server enforces, not a description of it.
| Door | Takes | Does |
|---|---|---|
POST share/link | node level label | Mint a public view or edit link for a bullet. The answer is the only time the secret is readable. |
POST share/invite | node email level | Invite an email address to a branch. |
POST capture | text note | One thought straight into the inbox. |
POST import | parent text format name | A whole file of bullets under a parent; format or a file name settles what it is. |
POST email/address | nodeId | Mint an email address that writes into one bullet. Sessions only. |
POST digest/now | preview | Send the daily digest to yourself right now. |
POST share/link/level | id level | Change what an existing link may do. |
POST share/link/revoke | id | Shut one link for everyone holding it. |
POST calendar/choose | calendars | Pick which connected calendars feed the day pages. |
POST account/forget | confirm | Delete the account and everything in it. Sessions only, and nothing here can be undone. |
POST files (?node=) | node | Attach raw bytes to a bullet; the vocabulary is the query string, the body is the file. |
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.