# 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](/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](/errors/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`.

```json
{
  "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 to `min`
- `{"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.

```json
{ "freq": "weekly", "interval": 1, "byday": [1, 3, 5], "until": "2026-12-31" }
```

- `freq` is `daily`, `weekly`, `monthly` or `yearly`. Required.
- `interval` is 1 to 999. Required.
- `byday` is required for `weekly`: ISO weekdays, Monday 1 through Sunday 7, non-empty and unique.
- `bymonthday` (1 to 31) is required for `monthly`, and for `yearly` alongside `bymonth` (1 to 12).
- `mode` is `schedule` (the default, a fixed grid) or `afterCompletion` (the next date counts from when you complete it). In `afterCompletion` the rule carries **only** `freq` and `interval`, plus the end conditions. A grid field there makes the rule invalid.
- `until` is the last date an occurrence may fall on, inclusive, `yyyy-MM-dd`.
- `count` is 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](/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](/data-model) for what the rows are.
- [Dates and time zones](/dates) for the date fields and the views.
- [The tools](/mcp) for the MCP surface, with arguments.
