TodoMode API reference (v1)
The TodoMode API lets your own scripts, a command line, or an AI assistant read and change your TodoMode data: projects, tasks, notes, due dates, the agenda, search, and the trash. It is a plain JSON-over-HTTPS REST API that acts as you, through a personal API token.
This page is the complete reference. It is written for people building against the API (a CLI, an MCP server, an integration) who should not need to read the server code.
Quick start #
- In TodoMode, open Settings → API, give a token a name, choose its access (read only, or read and write) and an expiry, and press Create token. Copy the token: it is shown once.
- Call the API with it:
export TODOMODE_TOKEN="tm_…" # the token you copied
curl -s https://app.todomode.com/api/v1/me \
-H "Authorization: Bearer $TODOMODE_TOKEN"
# Add a task with the same shorthand the app's input box understands
curl -s https://app.todomode.com/api/v1/tasks \
-H "Authorization: Bearer $TODOMODE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id": "proj_6ac7b898efa547.08938441", "text": "Fix login due:fri at:9am #work !high"}'
# What is on my plate this week?
curl -s https://app.todomode.com/api/v1/agenda \
-H "Authorization: Bearer $TODOMODE_TOKEN"
Base URL #
All endpoints live under one base URL:
https://app.todomode.com/api/v1
Every path in this reference is relative to it: GET /me means
GET https://app.todomode.com/api/v1/me.
The same endpoints are also reachable as https://app.todomode.com/api/v1/?path=/me (the path
in a query parameter, other parameters after it). Both spellings behave identically; use the
first unless something in between rewrites your URLs.
Authentication #
Every request carries a personal API token in the Authorization header:
Authorization: Bearer tm_xZ9rflq0Tn3hV7Kc2a8YwB6dE1pM4sJu5gRiOoLxNbQ
- Format.
tm_followed by 43 characters fromA-Z a-z 0-9 - _(32 random bytes). The regular expressiontm_[A-Za-z0-9_-]{43}matches a token; secret scanners can use it. - Scopes. A token is either
read(GET requests only) orwrite(everything in this reference). Choose read for anything that only looks: dashboards, exports, an assistant that summarises your day. - What it can reach. Exactly what you can in the app: your own projects and the projects shared with you, with the same limits. A project shared with you read-only stays read-only through the API, whatever the token's scope.
- Shown once. TodoMode keeps only a one-way hash of the token. It cannot show it to you again or recover it; if you lose it, revoke it and make a new one.
- Expiry. Optional, chosen at creation (30 days, 90 days, a year, or never). An expired token stops working at the end of its expiry day (UTC).
- Revoking. Settings → API → Revoke stops a token on the next request. Resetting a forgotten password revokes all of your tokens. Changing your password while signed in does not (revoke them yourself if that is what you want). Deleting your account deletes them.
- Limits. Up to 10 live tokens per account.
- No cookies. The API never reads or sets a cookie and never starts a session. A browser that is signed in to TodoMode is not signed in to the API.
Any problem with the token — missing, malformed, unknown, revoked, expired, or belonging to a suspended account — gets the same answer, so the response never tells a caller which it was:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="TodoMode API"
{"error": {"code": "unauthorized", "message": "Missing or invalid API token. Send \"Authorization: Bearer tm_…\"; create a token in Settings → API."}}
Requests and responses #
- Request bodies are JSON objects, sent with
Content-Type: application/json(otherwise 415). Bodies are limited to 6 MB. - Responses are JSON (
application/json; charset=utf-8) with one of three shapes:
// one resource
{"data": {...}, "meta": {...}} // meta only when there is something to add
// a list
{"data": [...], "next_cursor": "eyJvIjoxMDB9"} // null on the last page
// an error
{"error": {"code": "validation_failed", "message": "…", "details": {"field": "due"}}}
- Dates are
YYYY-MM-DDcalendar days in your timezone (due_date); times of day areHH:MM, 24-hour (due_time). Moments are ISO 8601 in UTC with aZ(created_at,completed_at,reminder.at). PUTis accepted as a synonym forPATCH; every update is partial.OPTIONSanswers204with anAllowheader.
Status codes and error codes #
| Status | error.code | When |
|---|---|---|
| 200 | Success. | |
| 201 | Created (a project, a task, an import). | |
| 304 | Not modified (see Conditional requests). | |
| 400 | invalid_parameter | A query parameter is malformed (limit, due, cursor, updated_since …). details.parameter names it. |
| 400 | invalid_json | The body is not a JSON object. |
| 401 | unauthorized | No usable token. Always the same body. |
| 402 | payment_required | A write on an account whose trial or subscription has ended. details.upgrade_url says where to fix it. Reads keep working. |
| 403 | insufficient_scope | A write with a read-only token. |
| 403 | forbidden | You can see the project but not change it (a read-only share), or only the owner may do this (renaming a project). |
| 404 | not_found | No such endpoint, or a project or task you cannot see — the API does not distinguish "does not exist" from "not yours". |
| 405 | method_not_allowed | The path exists but not with this method. Allow lists the ones that work. |
| 409 | notes_conflict | The notes changed since you read them (see Notes and the optimistic lock). |
| 409 | conflict | A move that cannot happen, or a client_request_id whose task has since been deleted or moved. |
| 413 | payload_too_large | Body over 6 MB. |
| 415 | unsupported_media_type | A body without Content-Type: application/json. |
| 422 | validation_failed | A body field is wrong. details.field names it. |
| 422 | unparseable_syntax | The task text has a shorthand token that could not be read (due:someday). details.errors lists them. See strict. |
| 429 | rate_limited | Too many requests; see Rate limits. |
| 500 | internal_error | Our fault. The message carries a reference to quote to support. |
| 503 | unavailable | Temporarily unavailable; retry after Retry-After. |
Rate limits #
Each token may make 120 requests per minute, of which at most 60 may be writes (anything other than GET). Windows are fixed, aligned to the minute. Every authenticated response says where you stand:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1791473760 # Unix time the window resets
X-RateLimit-Write-Limit: 60 # on writes
X-RateLimit-Write-Remaining: 59
Over the limit the answer is 429 rate_limited with Retry-After in seconds. Failed
authentication is limited separately, per IP address (30 a minute).
Pagination #
Lists of tasks return up to limit items (default 100, maximum 500) and a next_cursor. Pass
it back as cursor with the same other parameters to get the next page; it is null on the
last page. Treat cursors as opaque.
curl -s "https://app.todomode.com/api/v1/tasks?completed=false&limit=200" -H "Authorization: Bearer $TODOMODE_TOKEN"
curl -s "https://app.todomode.com/api/v1/tasks?completed=false&limit=200&cursor=eyJvIjoyMDB9" -H "Authorization: Bearer $TODOMODE_TOKEN"
Search is limited by limit alone (default 50, maximum 200). Projects, the trash and the agenda
are not paginated.
Conditional requests #
Successful GET responses carry a weak ETag. Send it back as If-None-Match and an unchanged
result answers 304 Not Modified with no body — a cheap way to poll.
Idempotent creates #
Network retries should not create duplicates. Send a key of your choosing (1–100 characters from
A-Z a-z 0-9 . _ : -) as client_request_id in the body, or as an Idempotency-Key header,
when creating a task or importing an outline. Repeating a request with the same key answers with
what the first one created: 200 instead of 201, and "meta": {"replayed": true} for a task
("created": false for an import). Keys are private to your account. If what the first request
created has since been deleted or moved to another project, the repeat answers 409 conflict:
send a new key to create it again.
Accounts whose trial or subscription has ended #
The API applies the same rule as the app. While your account can use TodoMode, write tokens can
write. When a trial or subscription has ended, every read keeps working — so you can take your
data out — and every write answers 402 payment_required, with details.reason
(trial_expired, subscription_ended, payment_failed …) and details.upgrade_url. GET /me
reports which applies in access.
Objects #
Project #
{
"id": "proj_6ac7b898efa547.08938441",
"title": "Work",
"permission": "owner",
"position": 0,
"open_tasks": 12,
"total_tasks": 30,
"created_at": "2026-10-08T15:36:56Z",
"updated_at": "2026-10-08T15:36:56Z",
"url": "https://app.todomode.com/?action=project&project=proj_6ac7b898efa547.08938441"
}
permissionisowner,edit(shared with you, may change) orread(shared with you, read only). Shared projects also carry"owner": {"name": "Bob"}.open_tasks/total_taskscount live, unarchived tasks.inbox. Wherever a request takes a project id — in the path or asproject_id—inboxnames your Inbox, the project that tasks captured by email or from the share sheet land in (it stays your inbox if you rename it). A write creates it the first time; a read of an account that has none yet is a 404.POST /taskswith{"project_id": "inbox", "text": "…"}is the quickest way to capture something.
Task #
{
"id": "task_6ac7b898efe3d9.96506938",
"project_id": "proj_6ac7b898efa547.08938441",
"parent_id": null,
"position": 0,
"indent": 0,
"text": "Fix login",
"completed": false,
"completed_at": null,
"priority": "high",
"due_date": "2026-10-09",
"due_time": "09:00",
"due_end_time": null,
"tags": ["work"],
"recurrence": null,
"reminder": {"mode": "offset", "offset": -3600, "at": "2026-10-09T06:00:00Z"},
"has_notes": false,
"archived": false,
"created_at": "2026-10-08T15:36:56Z",
"updated_at": "2026-10-08T15:36:56Z",
"url": "https://app.todomode.com/?action=project&project=proj_6ac7b898efa547.08938441&focus=task_6ac7b898efe3d9.96506938"
}
- The outline. A project is an outline stored as a flat list. Sort by
positionfor the order you see in the app;parent_idis the task it sits under (nullat the top level) andindentits depth (0 at the top, at most 50). Between a task and its last descendant there are only its own descendants, so a list inpositionorder rebuilds the tree in one pass. Positions may have gaps; only their order means anything. textis the title with the shorthand taken out. It may contain markdown (**bold**,[links](https://…)) and links to other tasks (see Links between tasks).priorityishigh,medium,lowornull.due_dateis a calendar day;due_timeanddue_end_timean optional time (and end of a span,at:9-10am) on it. Dates and times "float": Friday 09:00 reads the same to everyone.recurrenceisnullor{"type", "anchor", "until", "count"}.typeis one ofdaily,weekly,monthly,yearly,weekdays,every_N_days|weeks|months|years,weekly_mon[_wed…],every_N_weeks_mon,monthly_2nd_wed,monthly_day_15(or_last).anchorisdue(the next date counts from the scheduled one) orcompletion(from the day it is done, Todoist'severy!).untilis a last date,countthe occurrences left.reminderis the task's own reminder:modeoffset(offsetseconds relative to the due date and time, negative = before) orabsolute, andat, when it fires (UTC). Additional reminders are listed byGET /tasks/{id}.has_notessays whether the task has notes. The notes themselves (notes, plusnotes_hash) come withGET /tasks/{id}, or in lists withinclude=notes.archivedtasks are done and tidied away by automatic archiving; lists leave them out unless asked.
Endpoints #
GET / #
The API's name, version and where this reference lives. Needs a token like everything else.
GET /me #
Who the token belongs to, and what it may do.
curl -s https://app.todomode.com/api/v1/me -H "Authorization: Bearer $TODOMODE_TOKEN"
{
"data": {
"id": 1,
"email": "ann@example.com",
"name": "Ann",
"timezone": "Europe/Berlin",
"week_start": 1,
"today": "2026-10-08",
"plan": "active",
"access": {"read": true, "write": true, "reason": "active", "until": null},
"token": {"id": 1, "name": "Laptop CLI", "prefix": "tm_xZ9rfl", "scope": "write", "expires_at": null}
}
}
timezonedecides what "today" anddue:frimean for this user;week_startis 1 (Monday) to 7 (Sunday) and decides "this week" and "next week".access.writeis false for a read token or an account that may not write (see above);access.reasonsays why (active,trial,trial_expired,admin…) andaccess.untilwhen current access ends, if known.
GET /projects #
Your projects in your order, then the ones shared with you, by title. Not paginated.
curl -s https://app.todomode.com/api/v1/projects -H "Authorization: Bearer $TODOMODE_TOKEN"
GET /projects/{project_id} #
One project. 404 if you cannot see it.
POST /projects #
Create a project. Write scope.
curl -s https://app.todomode.com/api/v1/projects -H "Authorization: Bearer $TODOMODE_TOKEN" \
-H "Content-Type: application/json" -d '{"title": "Side project"}'
Answers 201 with the project. title is 1–255 characters.
PATCH /projects/{project_id} #
Rename a project: {"title": "New name"}. Write scope; only the project's owner (403 for a
shared project, as in the app).
Projects cannot be deleted through the API in v1: deleting a project is permanent and takes every task with it, with no trash to restore from. Do it in the app.
GET /projects/{project_id}/tasks and GET /tasks #
Tasks of one project in outline order, or (GET /tasks) of every project you can read —
project by project, each in outline order. Trashed tasks never appear; archived ones only on
request.
| Parameter | Meaning |
|---|---|
project_id | (GET /tasks only) one project, like the other form. |
completed | true or false; omit (or all) for both. |
due | A due-date horizon: overdue, today, tomorrow, thisweek, nextweek, later, yesterday, lastweek, any (has a date), none (has none). overdue means open and before today. Weeks follow the user's week_start. |
tag | Tasks with this tag (without #, case-insensitive). |
priority | high, medium or low. |
parent_id | Direct children of this task; root for top-level tasks. |
updated_since | ISO 8601 moment. Only tasks changed after it, oldest change first, plus deleted tombstones. See Keeping a copy in sync. |
include_archived | true to include archived tasks. |
include | notes to include each task's notes and notes_hash. |
limit, cursor | Pagination. |
# Open tasks due this week, across every project
curl -s "https://app.todomode.com/api/v1/tasks?completed=false&due=thisweek" -H "Authorization: Bearer $TODOMODE_TOKEN"
# One project's outline, with notes
curl -s "https://app.todomode.com/api/v1/projects/proj_6ac7b898efa547.08938441/tasks?include=notes" -H "Authorization: Bearer $TODOMODE_TOKEN"
GET /tasks/{task_id} #
One task, with its notes, notes_hash, and reminders — every additional reminder:
{"id", "mode", "offset", "at", "source"}, where at is in your timezone with its offset.
GET /tasks/{task_id}/backlinks #
The tasks whose title or note links to this one, in projects you can read. Trashed tasks are not
listed; a task you cannot read is a 404, as everywhere.
{
"data": [
{
"task_id": "task_6ac7b8a1c2d3e4.10293847",
"title": "Prep for Fix login",
"project_id": "proj_6ac7b898efa547.08938441",
"project_title": "Work",
"in": ["title"],
"completed": false,
"archived": false,
"url": "https://app.todomode.com/?action=project&project=proj_6ac7b898efa547.08938441&highlight=task_6ac7b8a1c2d3e4.10293847"
}
]
}
in is where the link sits: title, note, or both. title is plain text: links inside it
show the linked task's title, or (private link). Fetch the task for its raw text.
POST /projects/{project_id}/tasks and POST /tasks #
Add a task. Write scope, and edit access to the project. POST /tasks takes project_id in the
body; otherwise the two are identical.
| Field | Meaning |
|---|---|
text | Required. The title, read with the inline shorthand (below): "Fix login due:fri at:9am #work !high". Up to 2000 bytes as sent, and 1000 once the shorthand is taken out. |
notes | Markdown notes. |
due | A due date in words or ISO: "2026-10-20", "fri", "next week", "in 3 days", "tomorrow 9am" (a time sets due_time), or null. Up to 100 bytes. |
due_time, due_end_time | "09:00", "9am", "2:30pm", or null. A time without a date means today. |
priority | high, medium, low or null. |
tags | Array of tags, ["work", "q4"] (letters, digits, -, _; up to 50 characters each). |
recurrence | "weekly", "every 2 weeks", "every monday", "every! 3 days", or an object {"type", "anchor", "until", "count"}. The first occurrence is due when given, otherwise the next date the rule falls on. |
reminders | Array, each like an r: token: "-1h" (an hour before the due time), "due", "-2d", "tomorrow 9am", "2026-10-20 14:30". The first is the task's own reminder, the rest additional ones (up to 10). Each up to 100 bytes. |
parent_id | Make it the last child of this task. |
after_task_id | Put it right after this task and everything under it, as its sibling (same parent, same indent). With parent_id, the anchor must be one of that parent's children. |
client_request_id | Idempotency key (see Idempotent creates). |
strict | Default true: a shorthand token that cannot be read (due:someday) is a 422 unparseable_syntax and nothing is created. false does what the app does — drops the token, creates the task — and lists what was dropped in meta.warnings. |
Fields you send win over the same thing written in text: {"text": "Plan !low", "priority":
"high"} is high. Without parent_id or after_task_id the task goes to the end of the project
at the top level. Placement is always decided by the server against the live outline, so
concurrent writers cannot interleave subtrees.
curl -s https://app.todomode.com/api/v1/projects/proj_6ac7b898efa547.08938441/tasks \
-H "Authorization: Bearer $TODOMODE_TOKEN" -H "Content-Type: application/json" \
-d '{"text": "Water plants @ every! 3 days", "client_request_id": "cli-7f3a"}'
{
"data": {
"id": "task_6ac7b898f03cf6.56834407",
"text": "Water plants",
"due_date": "2026-10-11",
"recurrence": {"type": "every_3_days", "anchor": "completion", "until": null, "count": null},
"…": "…"
}
}
Answers 201 with the full task (as GET /tasks/{id}).
PATCH /tasks/{task_id} #
Change a task. Write scope; only the fields you send change. Takes every field of a create except placement, plus:
| Field | Meaning |
|---|---|
completed | true / false — same as the complete/uncomplete endpoints. |
expected_notes_hash | With notes: the notes_hash you read. See below. |
text is read with the shorthand, but a token that is absent clears nothing: renaming a task
keeps its date, tags, priority and recurrence. Clear things explicitly: "tags": [],
"priority": null, "due": null (the time goes with the date), "recurrence": null,
"notes": null. Reminders sent here are added; they do not replace existing ones. Re-sending the
recurrence a task already has does not move its current occurrence.
curl -s -X PATCH https://app.todomode.com/api/v1/tasks/task_6ac7b898efe3d9.96506938 \
-H "Authorization: Bearer $TODOMODE_TOKEN" -H "Content-Type: application/json" \
-d '{"due": "next mon", "tags": ["work", "auth"]}'
Answers 200 with the task. Completing a recurring task this way also returns
meta.next_occurrence.
Notes and the optimistic lock #
Notes are often long and edited in more than one place. To avoid overwriting someone else's
edit, read the task, keep its notes_hash, and send it back as expected_notes_hash with the
new notes. If the stored notes changed in the meantime, nothing is written and the answer is:
{"error": {"code": "notes_conflict", "message": "The notes were changed elsewhere since you read them.", "details": {"notes_hash": "9f86d0…"}}}
Fetch the task again, merge, and retry with the new hash. Without expected_notes_hash the
write simply wins. Either way the previous version is kept in the task's notes history in the
app.
POST /tasks/{task_id}/complete and POST /tasks/{task_id}/uncomplete #
Tick a task off, or reopen it. Write scope. Doing either twice is harmless. Completing a
recurring task creates its next occurrence exactly as in the app — a new task holding the rule —
and returns it as meta.next_occurrence; the completed one keeps no recurrence.
curl -s -X POST https://app.todomode.com/api/v1/tasks/task_6ac7b898efe3d9.96506938/complete \
-H "Authorization: Bearer $TODOMODE_TOKEN"
POST /tasks/{task_id}/move #
Move a task and everything under it to another project (the app's :move). Write scope, and
edit access to both projects. The tasks keep their ids, notes, tags, recurrence, reminders and
files.
| Field | Meaning |
|---|---|
project_id | Required. The destination. |
after_task_id | Land after this task's subtree in the destination, as its sibling. |
parent_id | Land as this task's first child. |
at_start | true: the top of the destination. Default: the end, at the top level. |
Answers with the moved task and meta.moved_ids / meta.count. Moving within one project
(reordering, indenting) is not in v1.
DELETE /tasks/{task_id} #
Move a task to the trash, with everything under it. Write scope. Add ?subtree=false to trash
only this task and keep its children (they move up to where it was). Trashed tasks stay
restorable for 30 days, then are deleted for good.
{"data": {"batch_id": "trash_6b8bf3963cea35fb1d6d", "task_ids": ["task_6ac7b898efe3d9.96506938"], "count": 1}}
GET /trash #
What is in the trash of projects you can edit, newest first: batch_id, project_id,
project_title, deleted_at, count, root_text (the first deleted task's title), days_left.
POST /trash/{batch_id}/restore #
Put a deleted batch back — in its old place if that still makes sense, otherwise at the end of
the project. Write scope. Answers project_id, restored (how many tasks), in_place,
task_ids. 404 once it has been restored or purged.
GET /search?q= #
Search like the app's search box, across every project you can read: words (all must match, in
titles or notes; word* for a prefix), "exact phrases", -excluded words, a OR b, and
operators — #tag (or tag:x), +name mentions, priority:high, due:today (any horizon
name), done:thisweek (today, yesterday, thisweek, lastweek), completed:true|false,
recurring:true|false. Archived and trashed tasks are not searched.
| Parameter | Meaning |
|---|---|
q | Required, up to 500 characters. |
limit | Up to 200 (default 50). |
curl -s -G https://app.todomode.com/api/v1/search --data-urlencode 'q=login #work' -H "Authorization: Bearer $TODOMODE_TOKEN"
data is the matching tasks, best first, each with project_title and notes_snippet (an
excerpt of the notes when the match was there). meta has projects (projects whose titles
match: id, title), total_tasks, and parsed (how the query was read).
GET /agenda #
"What is on my plate": open tasks with a due date across every project you can read, sorted by
day and time (timed tasks first within a day), grouped into the app's agenda sections —
overdue, today, tomorrow, thisweek (the rest of this week), nextweek, later.
| Parameter | Meaning |
|---|---|
within | The last section to include: today (overdue and today), tomorrow, thisweek (default), nextweek, all. |
{
"data": {
"today": "2026-10-08",
"timezone": "Europe/Berlin",
"within": "thisweek",
"sections": [
{"key": "overdue", "title": "Overdue", "start": null, "end": "2026-10-07", "tasks": []},
{"key": "today", "title": "Today", "start": "2026-10-08", "end": "2026-10-08", "tasks": []},
{"key": "tomorrow", "title": "Tomorrow", "start": "2026-10-09", "end": "2026-10-09",
"tasks": [{"id": "task_6ac7b898efe3d9.96506938", "text": "Fix login", "due_time": "09:00", "project_title": "Work", "…": "…"}]},
{"key": "thisweek", "title": "This week", "start": "2026-10-05", "end": "2026-10-11", "tasks": []}
]
}
}
Every section in range is listed, empty or not. A section's start/end are the dates it
covers; a task is in exactly one section.
POST /projects/{project_id}/import #
Paste an outline into a project in one request — handy for piping a file from a command line. Write scope.
| Field | Meaning |
|---|---|
text | Required. Indented text or a markdown list (-, *, 1., [ ]/[x]; nesting by indentation), OPML, or a Todoist CSV. Up to 5 MB and 10,000 items. #tags, !priority and @daily in lines work as typed. |
format | auto (default), text, opml, todoist_csv. |
parent_id, after_task_id | Where the block goes, as for a create; default the end of the project. |
client_request_id | Idempotency key. |
printf -- "- Groceries\n - milk\n - bread\n- Call the bank !high\n" | \
jq -Rs '{text: .}' | \
curl -s https://app.todomode.com/api/v1/projects/proj_6ac7b898efa547.08938441/import \
-H "Authorization: Bearer $TODOMODE_TOKEN" -H "Content-Type: application/json" -d @-
Answers 201 with {"count": 4, "first_id": "task_…", "created": true}.
The inline shorthand #
Task text sent to the API is read exactly as the app's input box reads it. Tokens are taken out of the title and set fields:
| Token | Sets | Examples |
|---|---|---|
!high !medium !low !1 !2 !3 | priority (the first one; a second stays in the title) | !high, !2 |
#tag | tags (after a space or at the start; # inside a URL is left alone) | #work #q4 |
@daily @weekly @monthly @yearly | recurrence | @weekly |
@ every … / @ every! … | recurrence (every! counts from completion) | @ every 2 weeks, @ every monday, friday, @ every 2nd wed, @ every 15th, @ every weekday until dec 31, @ every! 3 days |
due: | due date, optionally with a time | due:today, due:tomorrow 9am, due:fri, due:next week, due:in 3 days, due:2w, due:march 24, due:2026-12-25 |
at: | time of day (dates the task today if it has no date) | at:9am, at:14:30, at:9-10am, at:allday, at:none |
r: / remind: | reminders; every token counts | r:-1h, r:due, r:-2d, r:tomorrow 9am, r:in 30 minutes |
Date words, relative to the user's today in their timezone:
today/tod,tomorrow/tom,yesterday;- weekday names (
mon…sunday,tues,thur): the next one after today (a week ahead if it is today);next friis the one after that; next week: the first day of next week (by the user's week start);in 3 days,in a week,in 2 months,in 2 weeks from now, and the compact3d,2w,1m;march 24,24th mar,dec 25 2027(without a year: the next time that date comes round);- ISO
2026-12-25.
A token's value runs until the next token, #, !, @, or the words and/with: in
due:next friday #work, the date is "next friday".
Links between tasks #
A task's title or notes can link to another task. The stored form is the target's id, then an optional label:
Call the bank about [[task_6ac7b898efe3d9.96506938|Fix login]]
Background in [[task_6ac7b898efe3d9.96506938]]
- The id is what the link means, so renaming or moving the linked task never breaks it. The app always shows the linked task's current title.
- The label is only there so raw text reads sensibly. The server writes it, whatever you send: on every save it becomes the linked task's current title, but only when everyone who can read this task's project can also read the linked task. Otherwise the label is dropped (
[[task_…]]), so raw text never carries a title someone with access to it may not see. - You can send
[[Some title]](or[[Some title|alias]]) instead of an id. On save it is resolved to the one task with exactly that title, in the same project first and then in any project you can read, and stored as[[task_…|Some title]]. If no task or more than one matches, it is stored as you wrote it and shows as a broken link. - Links inside code (
`…`or fenced blocks) are just text. - A link to a task in the trash shows as broken and works again when it is restored; a task deleted for good leaves a broken link behind.
To follow a link, GET /tasks/{id} the id. GET /tasks/{id}/backlinks lists what links to a
task. Writes return the task as stored, so text and notes show the resolved form.
Keeping a copy in sync #
To mirror tasks (a CLI cache, an MCP server's index, a calendar bridge):
- Full load.
GET /tasks?include_archived=true(all pages). Remember the time you started the request (in UTC, from your clock or the response'sDateheader) as the checkpoint. - Changes. Every so often,
GET /tasks?updated_since=<checkpoint>&include_archived=true(all pages).dataholds every task created or changed since, oldest change first; upsert them byid.deletedlists the tasks moved to the trash since:{"id", "project_id", "deleted_at"}— drop them. Then move the checkpoint to when that request started. Overlap a second or two; upserts are idempotent. - A task restored from the trash comes back as a change. A task moved to another project comes back as a change with its new
project_id. A task archived by automatic archiving comes back as a change witharchived: true; one brought back from the archive in the app may not, so also resync in full once a day or so. - Resync in full if your checkpoint is more than 25 days old: the trash is purged after 30 days and a purged task leaves no tombstone. Also resync if a project disappears from
GET /projects(deleted in the app, or no longer shared with you) — its tasks are gone too.
Use If-None-Match on frequent polls of small lists; respect the rate limits (a minute between
change polls is plenty).
The command line and AI assistants #
You may not need to build anything:
todomode, the command line. One file, Python 3 only:todomode add "fix login due:fri #work",todomode ls Work,todomode today,todomode done 3,--jsoneverywhere. See the command line guide.- The hosted MCP server at
https://app.todomode.com/mcplets Claude Code, Claude Desktop, Cursor and other MCP clients read and add tasks with your token — tools such asagenda,search_tasks,add_task,update_task,complete_task. See Connect AI assistants.
If you are building your own:
- A CLI maps commands to single calls:
add "fix login due:fri #work"→POST /taskswithtext(the server parses the shorthand, in the user's timezone);today→GET /agenda?within=today;ls→GET /projects/{id}/tasks?completed=false;done <id>→POST /tasks/{id}/complete;import file.md→POST /projects/{id}/import;find <words>→GET /search. Sendclient_request_idon creates so a retried command cannot add twice, and usestrict(the default) so typos indue:are reported, not lost. - An MCP server of your own exposes tools that are each one call. Give the assistant a read token unless it should change things. Return
urlso a person can open the task in the app. - In-process use. Code running inside TodoMode itself (the hosted MCP server does this) does not need HTTP:
TodoModeApi::fromToken($db, $token)(includes/api_v1.php) authenticates a token and returns an object with one method per endpoint —me(),listProjects(),listTasks($query),createTask($fields),updateTask($id, $fields),setCompleted($id, true),moveTask(),trashTask(),restoreTrash(),search(),agenda(),importOutline()— returning the same arrays asdataabove and throwingApiException(status,errorCode,details) for the same errors. Scope, billing and sharing rules live in that class, so they apply however it is reached. Rate limiting is the caller's (ApiTokens::hit()).
Versioning #
- The version is in the path:
/api/v1. Within v1 we only make additive changes: new endpoints, new optional parameters, new fields in objects, newerror.codevalues for new situations. Clients should ignore fields they do not know. - A change that would break a v1 client — removing or renaming a field, changing a type or a default, tightening validation of something that worked — goes into
/api/v2, and v1 keeps working alongside it for at least 6 months after v2 ships, announced in the changelog and by email to users with active tokens. - Every response carries
X-TodoMode-API-Version: v1.
Security notes #
- Keep tokens secret. A token is a password for your account, limited by its scope. Store it in a password manager, an environment variable or your OS keychain — not in a repository. Prefer read tokens, and expiring tokens.
- Leaked a token? Revoke it in Settings → API; it stops at once. If you think your account itself was taken over, reset your password through "Forgot password": that revokes every token and signs out every session.
- Not for browsers on other sites. The API sends no CORS headers, so a web page on another origin cannot call it with your token or read its answers. Use it from servers, scripts and local tools.
- No cookies, no CSRF. The API ignores cookies entirely; the only credential is the
Authorizationheader, which another site cannot make your browser send. - What we store. A hash of each token (never the token), its name, scope, dates, the time and IP address of its last use, and a request count. Creating and revoking tokens is recorded in your account's security log; tokens themselves are never logged. Your data export (Settings → Data → Export All My Data) lists your tokens without their hashes.
- Two-factor authentication protects signing in; a token is a separate credential that does not ask for a second factor, which is why creating one asks for your password.
Not in v1 #
Deleting projects, reordering or indenting within a project, attachments and images, sharing and public links, deleting individual reminders, notes history, timers, webhooks. The app does all of these; tell us which you need.