the criteria grammar, and what the API checks
Filters
A saved filter is a name and a set of criteria. The criteria are the same grammar on both surfaces: MCP takes an object, and POST /v1/filters takes that same JSON serialised to a string in criteria, up to 10,000 characters.
The grammar
{
"v": 2,
"match": "all",
"criteria": [
{ "kind": "tag", "op": "is", "name": "home" },
{ "kind": "priority", "op": "at_least", "level": "medium" },
{ "kind": "deadline", "op": "within", "days": 7 }
]
}v is 2. match is required: all means a to-do has to satisfy every criterion, any means at least one. criteria is non-empty.
Each criterion is a kind with an operator and, for most operators, a value:
| kind | operators | value | matches when |
|---|---|---|---|
tag |
is, is_not, has_all |
name, the tag name, or names, several (two or more, no repeats) |
is: the to-do carries the tag (with names: any of them); is_not: none of them; has_all: every one |
project |
is, is_not |
id, a project or subproject id |
that id is anywhere in the to-do’s ancestor chain |
area |
is, is_not |
id, an area id |
the same test on an area |
date |
has, none, is, between, within |
has and none take none; is takes date, between takes from+to, within takes days |
tests the to-do’s DATE (whenDate): it has one / has none / is that day / falls in the range, ends included / is on or before today plus N, with no lower bound, so overdue is included |
deadline |
has, none, is, between, within |
the same five | the same five tests on the DEADLINE instead |
reminder |
has, none |
none | the to-do has a reminder set / has none |
repeat |
has, none |
none | the to-do repeats / does not |
weekday |
is, is_not |
days, ISO weekdays, Monday is 1 |
the to-do’s DATE falls on one of those weekdays. A to-do with no date matches NEITHER is nor is_not |
month |
is, is_not |
months, 1 to 12 |
the to-do’s DATE falls in one of those months. Same no-date rule as weekday |
priority |
at_least, is |
level: low, medium or high |
at_least matches that level and above; is matches exactly it. A to-do with no priority matches neither |
time |
has, none, between, before, after |
has and none take none; before and after take time (HH:mm); between takes from+to (HH:mm, ends included) |
tests the to-do’s START time (its schedule’s time). A to-do with no time matches only none |
length |
has, none, at_least, at_most, between |
has and none take none; at_least and at_most take minutes (1 to 1440); between takes from+to minutes |
tests the to-do’s LENGTH (its schedule’s duration, in minutes) |
subtasks |
has, none, has_open |
none | the to-do has subtasks / has none / has one or more not yet ticked (has_open) |
notes |
has, none |
none | a live note (not in the trash) is linked to the to-do / none is |
attachments |
has, none |
none | the to-do has attachments (in any of their places) / has none |
color |
has, none, is |
has and none take none; is takes color (a hex like #58A6FF, any case) |
tests the to-do’s own colour |
title |
contains, not_contains |
text, not blank |
the title or subtitle contains the text (case and accents ignored) / does not |
created |
in_last, is, between |
in_last takes days (1 to 365); is takes date; between takes from+to |
tests the day the to-do was made (its createdAt, in your time zone): within the last N days counting today / that day / the range, ends included |
list |
is, is_not |
list: inbox, anytime, someday or dated |
the to-do sits in that list (dated = it has a date) / does not |
Examples, one per shape:
{"kind":"tag","op":"has_all","names":["calls","budget"]}
{"kind":"project","op":"is","id":"8E4F2A1B-…"}
{"kind":"area","op":"is_not","id":"8E4F2A1B-…"}
{"kind":"date","op":"within","days":7}
{"kind":"deadline","op":"between","from":"2026-10-01","to":"2026-10-31"}
{"kind":"reminder","op":"has"}
{"kind":"repeat","op":"none"}
{"kind":"weekday","op":"is","days":[1,2,3,4,5]}
{"kind":"month","op":"is_not","months":[7,8]}
{"kind":"priority","op":"at_least","level":"medium"}
{"kind":"time","op":"between","from":"14:00","to":"15:00"}
{"kind":"length","op":"at_least","minutes":45}
{"kind":"subtasks","op":"has_open"}
{"kind":"notes","op":"has"}
{"kind":"attachments","op":"none"}
{"kind":"color","op":"is","color":"#58A6FF"}
{"kind":"title","op":"contains","text":"call"}
{"kind":"created","op":"in_last","days":7}
{"kind":"list","op":"is_not","list":"someday"}The rules behind the table
- Done to-dos never match. The universe is open plus someday to-dos; done ones belong to the completed view.
withinhas no lower bound, on dates and deadlines alike:within 7is "on or before today + 7", so anything overdue is included.0is valid and means "today or earlier".date nonematches a someday to-do (it has no date).date hascannot, for the same reason. There is no someday criterion: the someday list is the someday list.weekdayandmonthread the DATE, never the deadline. A to-do with no date matches neitherisnoris_not.projectandareawalk the whole ancestor chain, so a filter on an area matches to-dos inside its projects and subprojects.is_notincludes the to-dos with nothing there at all:tag is_not homematches an untagged to-do.
Creating one over REST
curl -s https://api.inittasks.com/v1/filters \
-X POST \
-H "Authorization: Bearer $INITTASKS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Due at home","criteria":"{\"v\":2,\"match\":\"all\",\"criteria\":[{\"kind\":\"tag\",\"op\":\"is\",\"name\":\"home\"},{\"kind\":\"deadline\",\"op\":\"within\",\"days\":7}]}"}'Note the criteria are a string, so the inner JSON is escaped.
What the API checks
Both surfaces parse the criteria with the same engine the apps run, and refuse anything that does not match the grammar. Over REST that is 422 validation_failed with the pointer /criteria; over MCP it is a tool error with the grammar in the message. A missing match, an unknown kind or operator, an empty criteria, a from after its to, a duplicate weekday, a fractional days: all refused, at the door, with nothing written.
What is stored is the engine’s own serialisation, not your bytes: the members are ordered kind, op, then the value. So a filter you read back may not be character-for-character what you sent while meaning exactly the same thing: compare the parsed object, never the string.
The trash
A filter goes to the trash and comes back as the apps do it: POST /v1/filters/{id}/trash and POST /v1/filters/{id}/restore, one write each. A filter in the trash matches nothing, GET /v1/filters leaves it out unless you ask for ?include_trashed=true, GET /v1/trash lists it for a credential that holds filters:read, and it is deleted for good 30 days after it was trashed. DELETE /v1/filters/{id} deletes one at once, live or trashed. Over MCP delete_filter trashes it. See trash.
The apps make three filters the first time an account is used (low, medium and high priority). They are ordinary rows: rename, change or delete them like any other, and a deleted one is not made again.
Inert filters
A filter row reports inert: true when the server cannot parse its stored criteria: a document written by a newer version of the API, or a v1 document written before the grammar changed. An inert filter matches nothing: GET /v1/filters/{id}/todos is an empty list, every count is 0, and the apps render it with a muted note rather than an error.
It is never partly applied. One clause this version does not understand makes the whole filter inert, because dropping the clause would silently widen the filter, and a filter that matches more than you asked for is worse than one that matches nothing.
Reading it back is one call:
curl -s "https://api.inittasks.com/v1/filters/$ID/todos" \
-H "Authorization: Bearer $INITTASKS_TOKEN"