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.
Where the two agree
Search is literally the same function. Both surfaces call one searchCorpus, so the corpus is identical: to-do titles, subtitles, descriptions, subtask titles and tags; container names, subtitles and descriptions; inbox text, urls and titles; note titles, bodies, tag names and folder names (an embed in a body by its name, never its id); attachment titles, urls and text. Trashed rows and archived or trashed subtrees are excluded, someday and completed rows are included, and matching is case-insensitive substring.
The trash, when you ask for it. GET /v1/search?include_trashed=true (MCP search with include_trashed: true) also returns the to-dos, notes and containers that only the trash keeps out: trashed themselves, or inside a trashed container. Each of those hits has trashed: true; every hit carries the member. Anything inside an archived container stays out either way, and an attachment or capture row is never returned as trashed. This is what the apps' search shows in its trash section; restore_trashed (or POST /v1/…/{id}/restore) brings one back.
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.
curl -s "https://api.inittasks.com/v1/search?q=passport" \
-H "Authorization: Bearer $INITTASKS_TOKEN"Each hit names its kind, its id, the label to show, the field that matched and a crumb (where it lives), plus trashed:
{
"data": [
{ "kind": "todo", "id": "418A0AE7-0E1B-493A-A1D1-167D8890A3AB", "label": "renew passport", "field": "title", "crumb": "travel", "trashed": false }
],
"next_cursor": null
}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. Every MCP tool needs the scopes of the REST route that does the same thing; the tools page lists them. /v1/views/* and /v1/search (and the MCP tools behind them) each need both todos:read and containers:read; a credential with only one of them is refused. /v1/views/notes is the exception: it needs notes:read, containers:read and inbox:read. Search then leaves out the kinds the credential cannot read: notes without notes:read, capture rows without inbox:read, attachments without attachments:read. 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 |
REST GET /v1/views/inbox |
unsorted to-do ids, then open capture row ids |
MCP get_inbox |
unsorted to-dos first, then open capture rows |
An unsorted to-do is one with no parent, no date, and no answer given yet. It is a row in the to-dos table, not a capture row, so GET /v1/inbox never returns it; /v1/views/inbox and get_inbox do.
A to-do leaves the inbox with one answer: a date, anytime, someday, a container, or no project. Its sorted field turns true in the same write, on both surfaces. See lists, sorting and areas.
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, url or note. PATCH /v1/inbox/{id} edits its content and its whenDate, the date a capture carries to the to-do it becomes; sending "whenDate": null clears it back to an undated capture. MCP capture_inbox reads a trailing date phrase out of the text ("call maria tomorrow"); POST /v1/inbox does not, so send whenDate yourself. You can also reorder it, archive it, park it in someday, delete it (POST /v1/inbox/{id}/delete, MCP delete_inbox_item: it is archived and a file it holds is deleted), or move it onto a container.
note means one thing now
note used to name two unrelated rows: an attachment kind, and an inbox capture that the MCP create_note tool made. Both are gone. A note is its own row, at /v1/notes and through the MCP create_note tool, and POST /v1/attachments takes url and text.
⚠ If your integration used create_note before, read its description again. The name is the same and the behaviour is not: it used to put a capture in the inbox, and it now files a note into a project. To capture a note for triage later, use capture_inbox with kind note on MCP, or POST /v1/inbox with kind: "note" on REST.
A note is not a to-do on either surface: no done state, no dates, and it never appears in a date view or any to-do count. It can carry tags, which a saved filter never matches. Notes have a count of their own, navCounts.notes (and homeCounts.notes, which adds the notes waiting in the inbox).
⚠ A note capture never becomes a to-do. POST /v1/inbox/{id}/move on one produces a note: under the area, project or subproject you name, or standalone when you send no parentId. It needs notes:write as well as the usual two scopes.
Filters: one grammar, both surfaces check it
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": 2,
"match": "all",
"criteria": [
{ "kind": "tag", "op": "is", "name": "home" },
{ "kind": "priority", "op": "at_least", "level": "medium" },
{ "kind": "deadline", "op": "within", "days": 7 }
]
}match is required (all means every criterion has to pass, any means one is enough), and criteria is non-empty. Each criterion is a kind with an operator: tag, project, area, date, deadline, reminder, repeat, weekday, month and priority. The whole table, with the value each operator takes, is on the filters page.
Both surfaces now parse it. The MCP tool boundary always did; POST /v1/filters and PATCH /v1/filters/{id} do as well, and answer validation_failed with the pointer /criteria for anything the engine would not understand. The old trap, a malformed rule accepted with 201 and quietly matching nothing, is gone, and so is the advice to read every filter back to find out.
Two things still differ from what you sent, and both are on purpose:
- What is stored is the engine's serialisation, not your bytes. Members come back ordered
kind,op, value. Compare the parsed object, never the string. - A stored filter can still be
inert. That flag means this version of the server cannot parse the criteria: a document from a newer API, or a v1 one written before the grammar changed. It matches nothing and the apps show a muted note. You cannot create one; you can read one.
⚠ v1 criteria are refused. {"v":1,"all":[…]}, with kinds and no operators (priority.min, deadline.withinDays, scheduled.state, where.containerId), is not accepted by either surface any more. The shapes map over directly: scheduled: dated / anytime are date has / date none, where is project is or area is, deadline.overdue is deadline within 0, and priority.min is priority at_least.
Renaming a tag: the same move on both surfaces
REST has PATCH /v1/tags/{id} with name; MCP has rename_tag with tag (the current name or id) and new_name. Both call the same function, so the rules are identical: the tag moves to the new name's id, every to-do, filter, attachment and note that used it is rewritten, a change of case does nothing, and renaming onto an existing tag is a merge that has to say merge: true. REST answers a refused merge with tag_exists; MCP answers with an error that tells the model to confirm and call again. rename_tag is a write, so a read-only connection does not have it. See tags.
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, checked by the same engine.
{ "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. Both surfaces refuse an invalid rule: REST answers validation_failed at /recurrenceRule, and MCP answers with an error that names the problem. See recurrence.
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 |
uncomplete_todo (or 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 with no project | PATCH /v1/todos/{id} with "sorted": true |
update_todo with no_project: true |
| 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 |
| delete a capture row | POST /v1/inbox/{id}/delete |
delete_inbox_item |
| duplicate a to-do | POST /v1/todos/{id}/duplicate |
duplicate_todo |
| make a subtask a to-do | POST /v1/todos/{id}/subtasks/{subtaskId}/promote |
subtask_to_todo |
| take an attachment out of a place | DELETE /v1/attachments/{id}/places/{parentId} |
detach_attachment |
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. One edit changes status as a consequence on both surfaces: a date, a deadline, a repeat or a start time given to a parked to-do makes it open (REST PATCH, MCP update_todo and bulk_update_todos). On MCP, status: "someday" sent together with one of them does not park: the date wins, as in the apps' task form. See the data model.
Sorting a capture row has three outcomes, decided by its kind. A text capture becomes a sorted to-do in the container you name, or a text attachment of it with "as": "attachment" (MCP: as_attachment: true). A note capture becomes a note there, and the response carries todo: null with the note. A url, image or file attaches instead, and the response carries todo: null with the attachment's id. todoId in place of parentId attaches any of them but a note to a to-do (MCP: name a to-do as the target). With no target a text capture becomes a sorted to-do with no project and a note capture becomes a standalone note; the other three are refused, because they have nothing to attach to. MCP's move_inbox_item always takes a target: "anytime" for the no-project to-do and "standalone" for the standalone note. A capture is filed once: moving one that is already filed is 409 conflict on REST and a refusal on MCP, so a retry never makes a duplicate.
MCP's update_todo does accept status, and performs the transition rather than a field write: done is complete_todo (a recurring to-do spawns its next occurrence), open on a done to-do is uncomplete_todo, and someday parks it. REST refuses status in the schema and gives you one route per transition. Same behaviour, two spellings.
Same task, two shapes
A few edits are one call on one surface and a field or a separate tool on the other:
| you want | REST | MCP |
|---|---|---|
| read one to-do in full | GET /v1/todos/{id} and GET /v1/attachments?parent_id= |
get_todo |
| set a start, a length, reminders | whenTime, durationMin, reminders on POST /v1/todos or PATCH /v1/todos/{id} |
time, duration_min, reminders on create_todo/update_todo |
| change one subtask | /v1/todos/{id}/subtasks/{subtaskId} |
add_subtask, update_subtask, delete_subtask |
| list, create, recolour, reorder tags | /v1/tags |
list_tags, create_tag, update_tag (rename: rename_tag) |
| delete a tag | DELETE /v1/tags/{id} |
delete_tag |
| reorder filters | PATCH /v1/filters/{id} with after/before |
move_filter |
| attachments | /v1/attachments |
list_attachments, add_attachment, update_attachment, attach_to, move_attachment, delete_attachment |
| upload a file | POST /v1/attachments/upload: the raw file up to 100 MB, or base64 in JSON up to about 3 MB |
upload_attachment (text or base64, 2 MB) |
| put a file in the inbox | POST /v1/inbox/upload (the same two forms) |
capture_file (text or base64, 2 MB) |
| edit a capture | PATCH /v1/inbox/{id} |
update_inbox_item |
| settings | GET/PATCH /v1/settings |
get_settings, set_week_start, set_reminder_defaults, set_calendar_prefs |
| several to-dos at once | one call per to-do | bulk_update_todos |
| the whole account | GET /v1/export |
not on MCP |
MCP names a reminder's offset before_min where REST says before, and returns time_text (16:00–17:00 (1h)) beside time. MCP's delete_attachment needs confirm: true; DELETE /v1/attachments/{id} does not ask.
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.