Skip to content
Twig docs Open Twig MenuClose

Twig’s API

v1

Read 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

  1. 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.
  2. Copy it: it is shown once, and the server keeps only its SHA-256. A lost token is revoked and made again.
  3. Send it with every request, as Authorization: Bearer twig_…. Keep it in an environment variable, TWIG_TOKEN below, and out of anything you share.
Request
curl -H "Authorization: Bearer $TWIG_TOKEN" \
  "https://usetwig.app/api/v1/notes/pages?type=person&limit=1"
Response
{
  "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 pageWhat it holds
id, nameThe name as written; a reference to it is [[name]], and the name or an alias finds it.
kindpage, journal or whiteboard.
typeWhat 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.
declaredTypeThe type the page itself declares, left out when it declares none.
fieldsA 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.
propsEvery 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.
hasPicturetrue 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, tagsThe other names that find it, and the tags its own properties give it.
countsBlocks, words, sentences, paragraphs and characters, counted as the app counts them.
copyA 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, favoriteA journal page’s day; true on a favorite. Left out when there is none.
createdAt, updatedAtMilliseconds since 1970; updatedAt is its last edit.
A blockWhat it holds
id, page, pageIdThe page it is on, by name and by id.
parentId, orderThe block it is nested under (null at the top) and its place among its siblings.
contentAs written, Markdown and key:: value lines included.
textWhat the app shows: no property or date lines, a block reference read out as the words it points at.
marker, priorityA task’s TODO, DOING, NOW, LATER, WAITING, DONE or CANCELED, and its A, B or C.
scheduled, deadlineIts SCHEDULED and DEADLINE days.
heading, properties, collapsedIts 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, blockRefsThe 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 is 2026-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 limit and offset and answers total and next. Ask again with offset set to next until it is null.

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.

Request
curl -H "Authorization: Bearer $TWIG_TOKEN" \
  "https://usetwig.app/api/v1/notes/pages?type=website&limit=1"
Response
{
  "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.

Request
curl -H "Authorization: Bearer $TWIG_TOKEN" \
  "https://usetwig.app/api/v1/notes/pages/Hacker%20News"
Response
{
  "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.

Request
curl -H "Authorization: Bearer $TWIG_TOKEN" \
  "https://usetwig.app/api/v1/notes/days/today"
Response
{
  "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.

Request
ID=b41c2d3e-4f5a-4b6c-9d7e-8f9a0b1c2d3e
curl -H "Authorization: Bearer $TWIG_TOKEN" \
  "https://usetwig.app/api/v1/notes/blocks/$ID"
Response
{
  "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
  }
}

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.

Request
curl -H "Authorization: Bearer $TWIG_TOKEN" \
  "https://usetwig.app/api/v1/notes/search?q=launch+plan&limit=1"
Response
{
  "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.

Request
curl -H "Authorization: Bearer $TWIG_TOKEN" \
  "https://usetwig.app/api/v1/notes/tasks?due_by=2026-10-31&limit=1"
Response
{
  "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.

Request
curl "https://usetwig.app/api/v1/notes/types"
Response
{
  "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.

RuleWhat it means
The bodyJSON, 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 answerok, 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.
NeverNothing 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).
TextAs 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.
Todaytoday, 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.
SettingsEvery 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.
RefusalsIn 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.

Request
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"
Response
{
  "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.

Request
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"
Response
{
  "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.

Request
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"
Response
{
  "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.

Request
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"
Response
{
  "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.

Request
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"
Response
{
  "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.

Request
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"
Response
{
  "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.

An error
{
  "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."
}
StatusWhen
400A 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.
401No token, or one that is not a token or has been revoked. The answer carries WWW-Authenticate: Bearer.
402The 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.
403The account is not on this server’s list (forbidden); or a write, with a token made to read (read_only_token).
404No page by that name, no block by that id, or a path the API does not have.
405A method the path does not take; Allow lists the ones it does. Nothing here deletes.
409A 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.
413A write’s body over 64 KB.
429More 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).
503The server’s own agent only: the sign-in service that vouches for its token did not answer.
507Cloud Sync’s 10 GB is used up: a write that would grow the graph waits for room.
500Something 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:

CodeStatusWhat to do
bad_request400Fix the request: the words say what is wrong.
unauthorized401Send a token from Settings → Assistants.
payment_required402The account needs Cloud Sync; in its grace it reads, and refuses writes.
forbidden403The account is not on this server’s list.
read_only_token403Make a token with Read and write.
not_found404List pages or search for the name; the OpenAPI document lists every path.
method_not_allowed405Use a method Allow lists.
conflict409Read it again, then try again.
too_large413Send at most 64 KB.
rate_limited429Wait as long as Retry-After says.
trail_full429Revert some changes in Settings, or wait for them to pass 30 days.
unavailable503Try again in a minute.
quota_full507Make room in Cloud Sync.
internal500Try again; report it if it keeps failing.

Limits

RuleWhat it means
Rate120 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.
WritesUp 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.
ListsAt 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.
Searchq is up to 1000 characters, 24 words and 26 parts.
FreshEvery 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.
BrowsersCall 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 agentThe 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.

WhatBeforeNow
typeThe declared type, left out when there was noneThe resolved type, always there; the declared one is declaredType
Order of pagesAs storedMost recently edited first
Files/api/files/<id> in content, file ids in propsNamed, never linked; no file id anywhere
A page’s recordWith nameLower and propsAtWithout them, with aliases, tags, fields, counts and the rest above
BlocksThe stored recordThe same keys, with page and text added
ListsEvery page, and every block on a pageFor 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
SearchPlain words that were not a query were a 400Plain words match blocks holding every one of them
Errorspage not found, bad pathWords 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 picturepicture-missed in propsNot 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 toolThe same thing here
searchGET /api/v1/notes/search
list_pagesGET /api/v1/notes/pages
read_pageGET /api/v1/notes/pages/{name}
read_dayGET /api/v1/notes/days/{date}
list_tasksGET /api/v1/notes/tasks
A block by its idGET /api/v1/notes/blocks/{id}
add_blockPOST /api/v1/notes/pages/{name}/blocks
create_pagePOST /api/v1/notes/pages
set_taskPOST /api/v1/notes/tasks/{id}
update_blockPATCH /api/v1/notes/blocks/{id}
set_propertyPATCH /api/v1/notes/pages/{name}/properties/{key}
set_propertiesPATCH /api/v1/notes/pages/{name}/properties
The types’ fields, in the tools’ descriptionsGET /api/v1/notes/types