Twig’s API
v1Read your graph from a script or a program, and add to it: pages with their types, fields, aliases, tags and counts, journal days, blocks, tasks and search, as JSON, with the token an assistant uses.
Getting in
- In Settings, under Assistants (with Cloud Sync), choose Read, or Read and write to change things too, name the token for where it goes, and choose Make.
- Copy it: it is shown once, and the server keeps only its SHA-256. A lost token is revoked and made again.
- Send it with every request, as
Authorization: Bearer twig_…. Keep it in an environment variable,TWIG_TOKENbelow, and out of anything you share.
curl -H "Authorization: Bearer $TWIG_TOKEN" \
"https://usetwig.app/api/v1/notes/pages?type=person&limit=1"{
"pages": [
{
"id": "8c1e2f4a-3b5d-4e6f-9a7b-1c2d3e4f5a6b",
"name": "Ada Lovelace",
"kind": "page",
"type": "person",
"declaredType": "person",
"favorite": true,
"aliases": ["Ada"],
"tags": ["mathematics"],
"fields": {
"Email": "[email protected]",
"Works at": ["Analytical Engines"],
"Knows": ["Charles Babbage", "Mary Somerville"]
},
"props": {
"email": "[email protected]",
"works-at": "[[Analytical Engines]]",
"knows": "[[Charles Babbage]], [[Mary Somerville]]",
"alias": "Ada",
"tags": "mathematics"
},
"hasPicture": true,
"counts": {
"blocks": 1,
"words": 8,
"sentences": 1,
"paragraphs": 1,
"characters": 55
},
"createdAt": 1789862400000,
"updatedAt": 1790640000000
}
],
"total": 12,
"next": 1
}The whole API, as OpenAPI 3.1, is at /api/v1/openapi.json on the same server, with an example request and answer for every route and for every MCP tool, under /api/mcp. It is open to anyone, since it describes and grants nothing: point a client generator or an API explorer at it.
The same token reads one of your own files, with the same header, at /api/files/<id>: the address a block keeps, in an export or a backup. The API names a file and never gives its id, so a script reads a file whose address it already has.
What comes back
| A page | What it holds |
|---|---|
id, name | The name as written; a reference to it is [[name]], and the name or an alias finds it. |
kind | page, journal or whiteboard. |
type | What it is: person, location, book, website, organization, event, artist, album, track, movie or tv-show as declared on it; journal, whiteboard or pdf; otherwise tag when it is used as a tag more than it is linked, else page. |
declaredType | The type the page itself declares, left out when it declares none. |
fields | A typed page’s fields that hold something, by their labels: Email, Works at, Rating. A relation is an array of the names of the pages it links. |
props | Every property as written, keys lowercase, [[links]] and all, less a file’s id: pdf::, file::, a website’s snapshot ones, a drawing’s sketch-scene::, and image:: when it holds a file (the picture Enrich keeps). An image:: link of your own is there. The app’s own bookkeeping is not: picture-missed::, Enrich’s note of a picture that did not come. |
hasPicture | true when the page has a picture of its own, one Twig keeps or an image:: link of yours; never where a kept one is. |
aliases, tags | The other names that find it, and the tags its own properties give it. |
counts | Blocks, words, sentences, paragraphs and characters, counted as the app counts them. |
copy | A website only: { at, url } when Twig keeps a copy of it, the day it was kept and the address it was of; null when it does not. The copy itself is not served here. |
day, favorite | A journal page’s day; true on a favorite. Left out when there is none. |
createdAt, updatedAt | Milliseconds since 1970; updatedAt is its last edit. |
| A block | What it holds |
|---|---|
id, page, pageId | The page it is on, by name and by id. |
parentId, order | The block it is nested under (null at the top) and its place among its siblings. |
content | As written, Markdown and key:: value lines included. |
text | What the app shows: no property or date lines, a block reference read out as the words it points at. |
marker, priority | A task’s TODO, DOING, NOW, LATER, WAITING, DONE or CANCELED, and its A, B or C. |
scheduled, deadline | Its SCHEDULED and DEADLINE days. |
heading, properties, collapsed | Its heading level, its own properties (less the outliner’s id:: and collapsed::), and whether it is folded. Left out when there is none. |
refs, tags, blockRefs | The pages it links or tags, by name; those it tags; the ids of the blocks it references. |
- Days
- a day in an answer is a number, YYYYMMDD:
20260928. A day you send is2026-09-28. - Files
- a file is named,
[picture: front.png]or[file: plans.pdf], never linked, and a file’s id is never in an answer. That is on purpose:/api/files/<id>opens the file for anyone who has the id, with no token, so an id handed to a script would outlive the token that read it. The same goes for the picture Enrich keeps on a typed page. - Lists
- every list takes
limitandoffsetand answerstotalandnext. Ask again withoffsetset tonextuntil it isnull.
List pages
GET /api/v1/notes/pages: pages, most recently edited first. type keeps one type (any type above); limit is 1 to 1000, and 1000 unless asked.
curl -H "Authorization: Bearer $TWIG_TOKEN" \
"https://usetwig.app/api/v1/notes/pages?type=website&limit=1"{
"pages": [
{
"id": "0e9d2c4b-7a61-4f38-b5c2-9d1e0f3a4b5c",
"name": "Hacker News",
"kind": "page",
"type": "website",
"aliases": [],
"tags": [],
"fields": {
"URL": "https://news.ycombinator.com",
"About": "Links for the intellectually curious"
},
"props": {
"url": "https://news.ycombinator.com",
"description": "Links for the intellectually curious"
},
"hasPicture": false,
"counts": {
"blocks": 1,
"words": 5,
"sentences": 1,
"paragraphs": 1,
"characters": 45
},
"copy": { "at": "2026-09-20", "url": "https://news.ycombinator.com" },
"createdAt": 1789862400000,
"updatedAt": 1789862400000
}
],
"total": 4,
"next": 1
}Read a page
GET /api/v1/notes/pages/{name}: one page by its name or an alias, in any case, percent-encoded, and its blocks in outline order: each block, then the ones nested under it. The blocks come in stretches of up to 1000, as a list’s items do: limit, offset, total and next count blocks. A name no page has is a 404.
curl -H "Authorization: Bearer $TWIG_TOKEN" \
"https://usetwig.app/api/v1/notes/pages/Hacker%20News"{
"page": {
"id": "0e9d2c4b-7a61-4f38-b5c2-9d1e0f3a4b5c",
"name": "Hacker News",
"kind": "page",
"type": "website",
"aliases": [],
"tags": [],
"fields": {
"URL": "https://news.ycombinator.com",
"About": "Links for the intellectually curious"
},
"props": {
"url": "https://news.ycombinator.com",
"description": "Links for the intellectually curious"
},
"hasPicture": false,
"counts": {
"blocks": 1,
"words": 5,
"sentences": 1,
"paragraphs": 1,
"characters": 45
},
"copy": { "at": "2026-09-20", "url": "https://news.ycombinator.com" },
"createdAt": 1789862400000,
"updatedAt": 1789862400000
},
"blocks": [
{
"id": "5d0c8e1a-2b3f-4c6d-8e9f-0a1b2c3d4e5f",
"page": "Hacker News",
"pageId": "0e9d2c4b-7a61-4f38-b5c2-9d1e0f3a4b5c",
"parentId": null,
"order": 0,
"content": "Front page on launch day [picture: front.png]",
"text": "Front page on launch day [picture: front.png]",
"refs": [],
"tags": [],
"blockRefs": [],
"createdAt": 1789862400000,
"updatedAt": 1789862400000
}
],
"total": 1,
"next": null
}Read a day
GET /api/v1/notes/days/{date}: a day’s journal page and its blocks, in stretches as a page’s are, for 2026-09-28, today or yesterday. Today is in the time zone of the device that made the token, else the one your daily digest keeps, else UTC, the same today MCP gives the token, and date says which day it was. A day nobody wrote on answers with page null.
curl -H "Authorization: Bearer $TWIG_TOKEN" \
"https://usetwig.app/api/v1/notes/days/today"{
"date": "2026-09-30",
"page": {
"id": "d3a1f0c2-6b4e-4d8a-9c7f-2e1b0a9d8c7e",
"name": "Sep 30th, 2026",
"kind": "journal",
"type": "journal",
"day": 20260930,
"aliases": [],
"tags": [],
"fields": {},
"props": {},
"hasPicture": false,
"counts": {
"blocks": 1,
"words": 6,
"sentences": 1,
"paragraphs": 1,
"characters": 36
},
"createdAt": 1790726400000,
"updatedAt": 1790751600000
},
"blocks": [
{
"id": "a7b8c9d0-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"page": "Sep 30th, 2026",
"pageId": "d3a1f0c2-6b4e-4d8a-9c7f-2e1b0a9d8c7e",
"parentId": null,
"order": 0,
"content": "Lunch with [[Ada Lovelace]] at noon",
"text": "Lunch with [[Ada Lovelace]] at noon",
"refs": ["Ada Lovelace"],
"tags": [],
"blockRefs": [],
"createdAt": 1790751600000,
"updatedAt": 1790751600000
}
],
"total": 1,
"next": null
}Read a block
GET /api/v1/notes/blocks/{id}: one block by the id a page, a search or a task gave. An id this account has no block for is a 404.
ID=b41c2d3e-4f5a-4b6c-9d7e-8f9a0b1c2d3e
curl -H "Authorization: Bearer $TWIG_TOKEN" \
"https://usetwig.app/api/v1/notes/blocks/$ID"{
"block": {
"id": "b41c2d3e-4f5a-4b6c-9d7e-8f9a0b1c2d3e",
"page": "Launch",
"pageId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"parentId": null,
"order": 2,
"content": "TODO [#A] Book the hall\nSCHEDULED: <2026-10-01 Thu>",
"text": "TODO [#A] Book the hall",
"marker": "TODO",
"priority": "A",
"scheduled": 20261001,
"refs": [],
"tags": [],
"blockRefs": [],
"createdAt": 1790640000000,
"updatedAt": 1790640000000
}
}Search
GET /api/v1/notes/search?q=…: blocks that match, most recently edited first. Plain words match blocks that hold every one of them, in any case. A query with a bracket or a quote is Twig’s query language, [[Page]], (todo TODO DOING), (and …), (or …), (not …), (between -7d today), (page-property author "Frank Herbert"), and one that does not parse is a 400 that says why. limit is 200 unless asked.
curl -H "Authorization: Bearer $TWIG_TOKEN" \
"https://usetwig.app/api/v1/notes/search?q=launch+plan&limit=1"{
"blocks": [
{
"id": "e5f6a7b8-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"page": "Launch",
"pageId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"parentId": null,
"order": 0,
"content": "The launch plan, for [[Ada Lovelace]] to read",
"text": "The launch plan, for [[Ada Lovelace]] to read",
"refs": ["Ada Lovelace"],
"tags": [],
"blockRefs": [],
"createdAt": 1790640000000,
"updatedAt": 1790726400000
}
],
"total": 3,
"next": 1,
"truncated": true
}List tasks
GET /api/v1/notes/tasks: blocks with a task marker, from every page. status is open (TODO, DOING, NOW, LATER, WAITING, soonest first, undated ones last), done (most recently finished first) or all. due_by=2026-10-31 keeps those whose day, the deadline, else the scheduled day, else a journal day the task links, is on or before it. limit is 100 unless asked.
curl -H "Authorization: Bearer $TWIG_TOKEN" \
"https://usetwig.app/api/v1/notes/tasks?due_by=2026-10-31&limit=1"{
"tasks": [
{
"id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
"page": "Sep 28th, 2026",
"pageId": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"parentId": null,
"order": 1,
"content": "TODO Call the printer\nDEADLINE: <2026-10-02 Fri>",
"text": "TODO Call the printer",
"marker": "TODO",
"deadline": 20261002,
"refs": [],
"tags": [],
"blockRefs": [],
"createdAt": 1790467200000,
"updatedAt": 1790467200000
}
],
"total": 4,
"next": 1
}List the types
GET /api/v1/notes/types: every type a page can be given, and its fields, from the table the app draws its pages from. It is open to anyone, with no token, as the OpenAPI document is: it describes and grants nothing. A field’s key is what props and a write use, its label what fields and a write use, and its kind how a value is written: date as YYYY-MM-DD; when as far as it is known (1976, 1976-04); stars 1 to 5; ref a name or [[Name]], several comma-separated, with of the kind of page it usually names; select one of its options, in any case, or words of your own; the rest as words. The answer below is cut to two of its twelve types.
curl "https://usetwig.app/api/v1/notes/types"{
"types": [
{
"type": "person",
"label": "Person",
"plural": "People",
"fields": [
{ "key": "email", "label": "Email", "kind": "email" },
{ "key": "phone", "label": "Phone", "kind": "tel" },
{ "key": "birthday", "label": "Birthday", "kind": "date" },
{ "key": "occupation", "label": "Occupation", "kind": "text" },
{
"key": "works-at",
"label": "Works at",
"kind": "ref",
"of": "organization"
},
{
"key": "lives-in",
"label": "Lives in",
"kind": "ref",
"of": "location"
},
{ "key": "knows", "label": "Knows", "kind": "ref", "of": "person" },
{ "key": "description", "label": "About", "kind": "text" },
{ "key": "url", "label": "Website", "kind": "url" },
{ "key": "country", "label": "Country", "kind": "text" },
{ "key": "died", "label": "Died", "kind": "when" },
{ "key": "roles", "label": "Roles", "kind": "text" }
]
},
{
"type": "location",
"label": "Location",
"plural": "Locations",
"fields": [
{ "key": "address", "label": "Address", "kind": "text" },
{
"key": "kind",
"label": "Kind",
"kind": "select",
"options": [
"Business",
"Government",
"Non-profit",
"Residence",
"School",
"Medical",
"Park",
"City",
"Other"
]
},
{ "key": "coordinates", "label": "Coordinates", "kind": "text" },
{ "key": "country", "label": "Country", "kind": "text" },
{ "key": "population", "label": "Population", "kind": "text" },
{ "key": "description", "label": "About", "kind": "text" }
]
}
]
}Writing
A token made to Read and write also makes the six writes an assistant makes over MCP: add a block, make a page, set a task, rewrite a block, set a property, set several properties. Each is the same write, taken by the server the same way, so it keeps the same rules and limits, and it is listed in Settings → Assistants beside an assistant’s, where you can put it back. A token made to read is answered 403 and nothing is written.
| Rule | What it means |
|---|---|
| The body | JSON, up to 64 KB, with only the keys the route takes; anything else is a 400 that names the key. The page, the block and the property go in the path, percent-encoded, as they do to read. |
| The answer | ok, changed, the ids it touched, and the block or the page as it now is. changed is false when it said so already (a task already done, a value already set): nothing is written then, and nothing listed. |
| Never | Nothing here deletes, moves or renames anything. Nothing writes a file’s address or a file’s id: text or a property holding an address, a pdf::, file::, snapshot:: or sketch-scene:: line, or an image:: holding a file’s id, is a 400 (an image:: link of your own is words); a block that holds a file or a picture keeps its words, and nothing changes or clears a picture Twig keeps (409). |
| Text | As you would type it: [[Name]] links a page and makes it if there is none, TODO starts a task, a line SCHEDULED: 2026-10-01 or DEADLINE: 2026-10-01 dates it, key:: value lines are its properties. Up to 20,000 characters a block, with no control characters but line breaks and tabs. |
| Today | today, yesterday and tomorrow are your days in the time zone of the device that made the token, else the one your daily digest keeps, else UTC, the same today it reads; the answer’s day says which day it was, and where. |
| Settings | Every change is listed for 30 days under Assistants, with the token’s name and “over the API”, and Revert puts it back on every device, as it does an assistant’s. Something you have changed since is left as it is, and says so. |
| Refusals | In the words an assistant is told, as JSON with a code and a hint: {"error": "\"text\" holds a control character; only line breaks and tabs are text", "code": "bad_request", "hint": "…"}. The codes are under Errors. |
Add a block
POST /api/v1/notes/pages/{name}/blocks: one block of text, always a new one, last on a page or a day, or first with "position": "start", or under one of its blocks with "parentId". {name} is a page’s name or alias, today, yesterday, tomorrow, or a day as 2026-10-01; a day nobody has written on yet is started, and a name no page has is a 404.
curl -X POST -H "Authorization: Bearer $TWIG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "Ring [[Ada Lovelace]] at noon\nSCHEDULED: 2026-10-01"
}' \
"https://usetwig.app/api/v1/notes/pages/today/blocks"{
"ok": true,
"changed": true,
"ids": {
"pageId": "journal-20260930",
"blockId": "0aabb44e-f37c-49f6-897d-1b1f70426898"
},
"block": {
"id": "0aabb44e-f37c-49f6-897d-1b1f70426898",
"page": "Sep 30th, 2026",
"pageId": "journal-20260930",
"parentId": null,
"order": 0.5,
"content": "Ring [[Ada Lovelace]] at noon\nSCHEDULED: <2026-10-01 Thu>",
"text": "Ring [[Ada Lovelace]] at noon",
"scheduled": 20261001,
"refs": ["Ada Lovelace"],
"tags": [],
"blockRefs": [],
"createdAt": 1790758860000,
"updatedAt": 1790758860000
},
"day": { "date": "2026-09-30", "zone": "Europe/London" }
}Make a page
POST /api/v1/notes/pages: a page with a name, a type if asked (person, location, book, website, organization, event, artist, album, track, movie, tv-show or tag), up to 30 properties and up to 100 blocks, top to bottom. A field is named by its key or by its label (Works at) and takes its value as the page shows it: a name or [[Name]] for a relation, a rating from 1 to 5, a birthday or an event’s date as YYYY-MM-DD, and a date of death, a founding or an end as far as it is known (1976, 1976-04). Every type’s fields are listed by List the types. A name that is already a page’s, or an alias, makes nothing: the answer is that page, with existed true.
curl -X POST -H "Authorization: Bearer $TWIG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Grace Hopper",
"type": "person",
"properties": {
"email": "[email protected]",
"Works at": "Navy"
},
"blocks": [
"Wrote the first compiler",
"TODO Read her papers"
]
}' \
"https://usetwig.app/api/v1/notes/pages"{
"ok": true,
"changed": true,
"ids": {
"pageId": "5b5040f8-43e6-4f77-b8d9-e10ddeb4d2d9",
"blockIds": [
"adf6d485-ff29-4ec6-a22d-475d270fda30",
"69ccd318-16c0-4b11-9fc6-05362b7455e8"
]
},
"page": {
"id": "5b5040f8-43e6-4f77-b8d9-e10ddeb4d2d9",
"name": "Grace Hopper",
"kind": "page",
"type": "person",
"declaredType": "person",
"aliases": [],
"tags": [],
"fields": { "Email": "[email protected]", "Works at": ["Navy"] },
"props": { "email": "[email protected]", "works-at": "[[Navy]]" },
"hasPicture": false,
"counts": {
"blocks": 2,
"words": 8,
"sentences": 2,
"paragraphs": 2,
"characters": 44
},
"createdAt": 1790758920000,
"updatedAt": 1790758920005
}
}Set a task
POST /api/v1/notes/tasks/{id}: a block’s task state, as the app’s own marker change sets it: todo, doing, done, later, now, waiting or canceled. A block that is not a task yet becomes one; the rest of its text stays.
ID=b41c2d3e-4f5a-4b6c-9d7e-8f9a0b1c2d3e
curl -X POST -H "Authorization: Bearer $TWIG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"state": "doing"
}' \
"https://usetwig.app/api/v1/notes/tasks/$ID"{
"ok": true,
"changed": true,
"ids": {
"pageId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"blockId": "b41c2d3e-4f5a-4b6c-9d7e-8f9a0b1c2d3e"
},
"block": {
"id": "b41c2d3e-4f5a-4b6c-9d7e-8f9a0b1c2d3e",
"page": "Launch",
"pageId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"parentId": null,
"order": 2,
"content": "DOING [#A] Book the hall\nSCHEDULED: <2026-10-01 Thu>",
"text": "DOING [#A] Book the hall",
"marker": "DOING",
"priority": "A",
"scheduled": 20261001,
"refs": [],
"tags": [],
"blockRefs": [],
"createdAt": 1790640000000,
"updatedAt": 1790758980000
}
}Rewrite a block
PATCH /api/v1/notes/blocks/{id}: new words for a block; it keeps its id, its place and the blocks under it, and whatever its old text carried that the new does not say: its task marker, priority, quote, heading, SCHEDULED and DEADLINE lines, and each key:: value property. Text that is only properties and dates changes those and keeps the words. It changes a marker, a priority, a date or a property and never removes one. A block holding a file or a picture keeps its words, with a 409.
ID=b41c2d3e-4f5a-4b6c-9d7e-8f9a0b1c2d3e
curl -X PATCH -H "Authorization: Bearer $TWIG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "Book the big hall"
}' \
"https://usetwig.app/api/v1/notes/blocks/$ID"{
"ok": true,
"changed": true,
"ids": {
"pageId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"blockId": "b41c2d3e-4f5a-4b6c-9d7e-8f9a0b1c2d3e"
},
"block": {
"id": "b41c2d3e-4f5a-4b6c-9d7e-8f9a0b1c2d3e",
"page": "Launch",
"pageId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"parentId": null,
"order": 2,
"content": "DOING [#A] Book the big hall\nSCHEDULED: <2026-10-01 Thu>",
"text": "DOING [#A] Book the big hall",
"marker": "DOING",
"priority": "A",
"scheduled": 20261001,
"refs": [],
"tags": [],
"blockRefs": [],
"createdAt": 1790640000000,
"updatedAt": 1790759040000
}
}Set a property
PATCH /api/v1/notes/pages/{name}/properties/{key}: one property of a page, found by its name or an alias, {"value": "…"} to set it and {"value": null} to take it off; the key type sets what the page is. A field is found by its key or its label and written as the page keeps it, and key in the answer says which. A value it has already changes nothing. A picture Twig keeps in image::, which props never shows, is left as it is, with a 409; an image:: link of your own changes like any value.
curl -X PATCH -H "Authorization: Bearer $TWIG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"value": "1815-12-10"
}' \
"https://usetwig.app/api/v1/notes/pages/Ada/properties/Birthday"{
"ok": true,
"changed": true,
"ids": { "pageId": "8c1e2f4a-3b5d-4e6f-9a7b-1c2d3e4f5a6b" },
"page": {
"id": "8c1e2f4a-3b5d-4e6f-9a7b-1c2d3e4f5a6b",
"name": "Ada Lovelace",
"kind": "page",
"type": "person",
"declaredType": "person",
"favorite": true,
"aliases": ["Ada"],
"tags": ["mathematics"],
"fields": {
"Email": "[email protected]",
"Birthday": "1815-12-10",
"Works at": ["Analytical Engines"],
"Knows": ["Charles Babbage", "Mary Somerville"]
},
"props": {
"email": "[email protected]",
"works-at": "[[Analytical Engines]]",
"knows": "[[Charles Babbage]], [[Mary Somerville]]",
"alias": "Ada",
"tags": "mathematics",
"birthday": "1815-12-10"
},
"hasPicture": true,
"counts": {
"blocks": 1,
"words": 8,
"sentences": 1,
"paragraphs": 1,
"characters": 55
},
"createdAt": 1789862400000,
"updatedAt": 1790759100000
},
"key": "birthday"
}Set several properties
PATCH /api/v1/notes/pages/{name}/properties: up to 30 of a page’s properties in one write, {"properties": {"key": "value", "other": null}}, each as Set a property takes it, null to take one off. type among them goes first, and the other keys are read as fields of the type the page will have, so an untyped page can become a person and get its birthday in the same call. A key that is no field of the type, Birthdy for Birthday, is kept as a property of its own, as the app keeps one, so keys and fields in the answer are where to see what each key became. Every key is checked before anything is written: a value a field cannot take, a key given twice by its key and its label, a type that is not one, or a key no property can have, is a 400 that writes none, and a picture Twig keeps among them is a 409 that writes none. It is one change in Settings, put back with one Revert, and keys in the answer lists each key as the page keeps it, type first.
curl -X PATCH -H "Authorization: Bearer $TWIG_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"type": "person",
"birthday": "1791-12-26",
"occupation": "Mathematician",
"Knows": "Ada Lovelace"
}
}' \
"https://usetwig.app/api/v1/notes/pages/Charles%20Babbage/properties"{
"ok": true,
"changed": true,
"ids": { "pageId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e" },
"page": {
"id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
"name": "Charles Babbage",
"kind": "page",
"type": "person",
"declaredType": "person",
"aliases": [],
"tags": [],
"fields": {
"Birthday": "1791-12-26",
"Occupation": "Mathematician",
"Knows": ["Ada Lovelace"]
},
"props": {
"birthday": "1791-12-26",
"occupation": "Mathematician",
"knows": "[[Ada Lovelace]]"
},
"hasPicture": false,
"counts": {
"blocks": 0,
"words": 0,
"sentences": 0,
"paragraphs": 0,
"characters": 0
},
"createdAt": 1788253200000,
"updatedAt": 1790778060940
},
"keys": ["type", "birthday", "occupation", "knows"]
}Errors
Every error is JSON with three fields: error, what went wrong in words, the same words MCP gives an assistant; code, what kind of error it is, for a program to act on; and hint, what to do about it. A code never changes meaning, and a client written before there were codes reads error as it always did. MCP’s JSON-RPC errors (bad arguments, a token that only reads) carry the same code and hint in error.data; a tool that refuses what it was asked (no such page, the write limits, a full list of changes) answers a result marked isError, in words only.
{
"error": "slow down: at most 120 requests a minute from one address",
"code": "rate_limited",
"hint": "Wait as long as Retry-After says, then go on."
}| Status | When |
|---|---|
| 400 | A parameter out of range or missing, a day not on the calendar, a query that does not parse, a name that is not valid percent-encoding. A write: a body that is not JSON or holds a key the route does not take, text that is empty, too long, holds a control character or names a file, a page name that is a day, a block that is not on the page named. |
| 401 | No token, or one that is not a token or has been revoked. The answer carries WWW-Authenticate: Bearer. |
| 402 | The account is not on Cloud Sync, or in the 30 days of grace after it: the server keeps no graph to read. In the 30 days of grace it still reads, and a write is refused. |
| 403 | The account is not on this server’s list (forbidden); or a write, with a token made to read (read_only_token). |
| 404 | No page by that name, no block by that id, or a path the API does not have. |
| 405 | A method the path does not take; Allow lists the ones it does. Nothing here deletes. |
| 409 | A rewrite of a block that holds a file or a picture, or a change to a page’s image:: when it is a picture Twig keeps; or something else changed it at the same moment, so not all of the write landed. Read it again, then try again. |
| 413 | A write’s body over 64 KB. |
| 429 | More than 120 requests a minute from one address, or more than 30 writes a minute from one token or 45 from one address: Retry-After says to wait a minute (rate_limited). Also a write while the list of changes in Settings is full of ones you can still revert, which no wait mends (trail_full). |
| 503 | The server’s own agent only: the sign-in service that vouches for its token did not answer. |
| 507 | Cloud Sync’s 10 GB is used up: a write that would grow the graph waits for room. |
| 500 | Something failed inside the server; the words say no more than that. Try again. |
Every code, the status it comes with, and what to do about it:
| Code | Status | What to do |
|---|---|---|
bad_request | 400 | Fix the request: the words say what is wrong. |
unauthorized | 401 | Send a token from Settings → Assistants. |
payment_required | 402 | The account needs Cloud Sync; in its grace it reads, and refuses writes. |
forbidden | 403 | The account is not on this server’s list. |
read_only_token | 403 | Make a token with Read and write. |
not_found | 404 | List pages or search for the name; the OpenAPI document lists every path. |
method_not_allowed | 405 | Use a method Allow lists. |
conflict | 409 | Read it again, then try again. |
too_large | 413 | Send at most 64 KB. |
rate_limited | 429 | Wait as long as Retry-After says. |
trail_full | 429 | Revert some changes in Settings, or wait for them to pass 30 days. |
unavailable | 503 | Try again in a minute. |
quota_full | 507 | Make room in Cloud Sync. |
internal | 500 | Try again; report it if it keeps failing. |
Limits
| Rule | What it means |
|---|---|
| Rate | 120 requests a minute from one address, across every route that takes a token and every token; a write is one of them. The OpenAPI document and the types, which take none, are not counted. |
| Writes | Up to 30 a minute from one token and 45 from one address over all its tokens, counted with the token’s writes over MCP. A block’s text up to 20,000 characters, a page’s name 200, a property’s key 64 and its value 2,000, a page’s properties 65,536 in all, the name of every key cleared from it counted too, since each stays on the page so sync cannot bring it back (one already past that may shrink, never grow; characters as JavaScript counts them, so text outside Latin can take up to three bytes each), a new page up to 30 properties and 100 blocks, a body 64 KB. While 500 changes in Settings can still be reverted, the next write is refused until you revert some or they pass 30 days. |
| Lists | At most 1000 items an answer: pages, and a page’s or a day’s blocks, come 1000 at a time unless limit asks for fewer; search answers 200 and tasks 100 unless asked. |
| Search | q is up to 1000 characters, 24 words and 26 parts. |
| Fresh | Every answer is read from what your devices have synced, as of the last sync, and is never cached (Cache-Control: no-store). A write is synced to your devices at their next sync. |
| Browsers | Call it from a script or a server, where a token belongs: the data routes send no CORS headers, so a page on another site gets no answer it can read, and cannot send a write. The OpenAPI document and the types are the exceptions, open to any page: they describe, and read no account. |
| The agent | The server’s own agent, with its machine-to-machine token, reads. Only a token from Settings writes. |
Changes since 0.60
The four routes under /api/v1/notes were first open only to the server’s own agent. They keep their paths and their keys; for a caller written against them, this is what changed.
| What | Before | Now |
|---|---|---|
type | The declared type, left out when there was none | The resolved type, always there; the declared one is declaredType |
| Order of pages | As stored | Most recently edited first |
| Files | /api/files/<id> in content, file ids in props | Named, never linked; no file id anywhere |
| A page’s record | With nameLower and propsAt | Without them, with aliases, tags, fields, counts and the rest above |
| Blocks | The stored record | The same keys, with page and text added |
| Lists | Every page, and every block on a page | For a token, 1000 at a time with total and next; the server’s own agent still gets them whole unless it asks for a limit |
| Search | Plain words that were not a query were a 400 | Plain words match blocks holding every one of them |
| Errors | page not found, bad path | Words that say what was not found or not valid |
| Error codes | {"error": "…"} | error as before, with code and hint beside it. Additive: a client that reads error needs no change |
| A page’s picture | picture-missed in props | Not in props; hasPicture says whether there is one |
Every change here adds: a client that reads the keys it knows needs no change. One that holds answers to an OpenAPI document from before these changes, rejecting keys it was not told of, does: Page now requires hasPicture, and Error requires code and hint. Fetch the document again.
MCP and this API
One token opens both, and both read the same graph and write it the same way: one write path on the server, one list of changes in Settings, one pair of write limits. MCP answers a model, in Markdown capped at 100,000 characters; this API answers a program, in JSON with every field. Revoking the token closes both at once.
| MCP tool | The same thing here |
|---|---|
search | GET /api/v1/notes/search |
list_pages | GET /api/v1/notes/pages |
read_page | GET /api/v1/notes/pages/{name} |
read_day | GET /api/v1/notes/days/{date} |
list_tasks | GET /api/v1/notes/tasks |
| A block by its id | GET /api/v1/notes/blocks/{id} |
add_block | POST /api/v1/notes/pages/{name}/blocks |
create_page | POST /api/v1/notes/pages |
set_task | POST /api/v1/notes/tasks/{id} |
update_block | PATCH /api/v1/notes/blocks/{id} |
set_property | PATCH /api/v1/notes/pages/{name}/properties/{key} |
set_properties | PATCH /api/v1/notes/pages/{name}/properties |
| The types’ fields, in the tools’ descriptions | GET /api/v1/notes/types |