the same data through two surfaces, and where the words differ
REST and MCP
The REST API and the MCP server read and write the same rows, under the same scopes, with the same encryption. They do not use the same words for everything, and a few differences will bite you if you move between them.
Everything below was read out of the server code, not out of the other docs.
Where the two agree
Search is literally the same function. Both surfaces call one searchCorpus, so the corpus is identical: to-do titles, subtitles, notes and tags; container names, subtitles and notes; inbox text, urls and titles; attachment titles, urls and text. Trashed rows and archived or trashed subtrees are excluded, someday rows are included, and matching is case-insensitive substring.
GET /v1/search is cursor-paged like every other list: ?limit= and ?cursor=, and next_cursor is null on the last page. It takes no ?updated_since=, because a hit carries no timestamp to filter on.
The view engine is the same engine. /v1/views/* runs the computation the apps run, on the same dataset. It is not a re-derivation.
Scopes are the same. /v1/views/* and /v1/search each need both todos:read and containers:read. A credential with only one of them is refused. See scopes.
Inbox means two different sets
Of everything here, this is the one most likely to surprise you.
| what "the inbox" holds | |
|---|---|
REST GET /v1/inbox |
capture rows only |
MCP get_inbox |
unfiled to-dos first, then open capture rows |
An unfiled to-do is one with no parent and no date. It is a row in the to-dos table, not a capture row, so REST returns it from /v1/todos and never from /v1/inbox.
A to-do leaves the inbox the moment it gets a parent or a date. There is no inbox flag to set, on either surface.
So "what is in my inbox?" asked of an AI client and GET /v1/inbox asked of your code can legitimately return different things. Neither is wrong.
A capture row is created with kind text or url. PATCH /v1/inbox/{id} edits its content and its whenDate, the date quick-add parsed out of the capture; sending "whenDate": null clears it back to an undated capture. You can also archive it, park it in someday, or move it onto a container. There is no delete route, on either surface. Archiving is how the product deletes a capture row.
note means two different things
| where | what it is |
|---|---|
POST /v1/attachments, kind: "note" |
an attachment on a to-do, container or inbox row |
MCP create_note |
a standalone inbox row of kind note |
They are unrelated rows. A note is not a to-do on either surface: it has no done state and never appears in a date view.
⚠ A note cannot move to anytime. POST /v1/inbox/{id}/move with parentId null or omitted means anytime, and only a text capture may do that. A note is refused with validation_failed (422), the pointer at /parentId and the detail naming the rule. Give it a parentId and it becomes a to-do in that container, the same as a text capture does.
⚠ The REST inbox surface does not know the note kind. POST /v1/inbox accepts text and url only, and the GET /v1/inbox?kind= filter accepts text, url, file and image. A note created over MCP comes back in an unfiltered GET /v1/inbox and cannot be selected by kind.
Filters: one grammar, two levels of checking
Criteria are the same JSON on both surfaces. MCP takes it as an object; REST takes the same JSON serialised to a string in criteria.
{
"v": 1,
"all": [
{ "kind": "tag", "name": "home" },
{ "kind": "priority", "min": "medium" },
{ "kind": "deadline", "withinDays": 7 }
]
}all is non-empty and AND-composed. A criterion is one of:
{"kind":"tag","name":"<tag name>"}{"kind":"priority","min":"low"|"medium"|"high"}, matching priority greater than or equal tomin{"kind":"deadline","withinDays":0..365}, meaning deadline on or before today plus N, overdue included{"kind":"deadline","overdue":true}{"kind":"scheduled","state":"dated"|"anytime"|"someday"}{"kind":"where","containerId":"<id>"}, matching when that id is anywhere in the to-do's ancestor chain
The checking differs, and this is a real trap. The MCP tool boundary parses criteria and refuses a shape it does not understand. POST /v1/filters validates only that criteria is a string of 1 to 10,000 characters. Send malformed JSON over REST and you get 201 Created and a filter that is inert: it parses to nothing at render time, matches nothing, and the apps show it with a muted note.
So validate the JSON yourself before you POST it. Create the filter, then read it back with GET /v1/filters/{id}/todos and check you get the matches you expect.
Recurrence: one grammar, two shapes
MCP takes a structured repeat object. REST takes recurrenceRule, the same rule serialised to a JSON string, at most 512 characters.
{ "freq": "weekly", "interval": 1, "byday": [1, 3, 5], "until": "2026-12-31" }freqisdaily,weekly,monthlyoryearly. Required.intervalis 1 to 999. Required.bydayis required forweekly: ISO weekdays, Monday 1 through Sunday 7, non-empty and unique.bymonthday(1 to 31) is required formonthly, and foryearlyalongsidebymonth(1 to 12).modeisschedule(the default, a fixed grid) orafterCompletion(the next date counts from when you complete it). InafterCompletionthe rule carries onlyfreqandinterval, plus the end conditions. A grid field there makes the rule invalid.untilis the last date an occurrence may fall on, inclusive,yyyy-MM-dd.countis occurrences remaining including the active one, 1 to 999, decremented on each completion.
The grammar is closed. An unknown field, an unknown mode, a duplicate weekday or a fractional count makes the whole rule invalid, and an invalid rule does not raise an error. The row is simply treated as non-recurring. As with filters, write it and read it back.
Transitions are not field writes
Neither surface lets you set status or parentId with a patch.
| you want | REST | MCP |
|---|---|---|
| complete | POST /v1/todos/{id}/complete |
complete_todo |
| un-complete | POST /v1/todos/{id}/uncomplete |
update_todo with status: "open" |
| park in someday | POST /v1/todos/{id}/someday |
update_todo with status: "someday" |
| detach and unschedule | POST /v1/todos/{id}/anytime |
move_to_anytime |
| reparent or reorder | POST /v1/todos/{id}/move |
move_todo |
| sort a capture row | POST /v1/inbox/{id}/move |
move_inbox_item |
| archive a capture row | POST /v1/inbox/{id}/archive |
archive_inbox_item |
| park a capture row | POST /v1/inbox/{id}/someday |
inbox_to_someday |
They are transitions with side effects. Completing a recurring to-do spawns the next occurrence; parking a plain to-do strips its dates. A patch that wrote the column would do half of each. See the data model.
Sorting a capture row has two outcomes, decided by its kind. A text or note becomes a to-do in the container you name; a url, image or file attaches to it instead, and the response carries todo: null with the attachment's id. With no parentId the target is anytime, which only a text capture may do.
MCP's update_todo does accept status, and performs the transition rather than a field write. REST refuses it in the schema and gives you one route per transition. Same behaviour, two spellings.
Next
- The data model for what the rows are.
- Dates and time zones for the date fields and the views.
- The tools for the MCP surface, with arguments.