Skip to content

About

Project instructions for the Todoist MCP connector, written for small models and for use in the web app

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

Todoist MCP — project instructions that survive contact with a small model

This is a paste-in project instructions file for a claude.ai Project with the Todoist connector enabled. It is written to be read by a cheap, fast model on a phone, mid-walk, with no patience for a failed tool call. Open Claude.ai on your phone and go to projects. Start a project called todoist or whatever you like and you'll see an option for agent "instructions". To my mind this is essentially a claude.md. Paste the project instructions in there after : # My Tasks — Project Instructions

You need to open a verbal or messaging chat in this project for these instructions to work. I do it everyday tidying the house or walking to the station chatting to Claude on my headphones.

Every recipe below was executed against a live Todoist account in a disposable sandbox project that was deleted afterwards. Nothing here is quoted from documentation alone, and where something could not be tested, it says so.

Design principle: encode only what the tools cannot discover. No project IDs, no project names, no label lists, no filter contents. All of that gets read live at the start of a conversation. A hardcoded ID that goes stale fails SILENTLY — it returns an empty list, not an error, which is indistinguishable from "nothing due" and is the worst possible failure mode. Rules and semantics only.

One exception, and it's deliberate: if you keep a personal label system with axes (categories) that the tool has no way to expose — most connectors return a flat list of label names with no grouping information — that convention has nowhere else to live except this file. It rots differently from a stale ID: a stale ID returns an empty list that looks like a correct answer, while a stale label is caught by a live reconciliation step and stops you before you write it. See Part 2 and Part 5.

Behaviours found that the tool descriptions do not state, all destroying, duplicating or corrupting data while reporting success unless noted: the recurrence wipe (Rule 3), the subtask cascade (Rule 11), the project delete cascade (Part 3, Delete), non-atomic batch writes that leave orphans after an error (Rule 13), filters being creatable but not deletable — no data loss, just a one-way door (Rule 14), unknown label names being accepted silently (Rule 15), label names being case-sensitive (Rule 16). Also established by bisection: the location-reminder radius ceiling of 199 metres.

Two further findings are about the connector session itself, not the Todoist API. A "has not been loaded yet" error that reads like a rejected call but is actually the tool's schema not having been warmed up in that session yet — fixable with a bare retry (Rule 19). And, found from a real session where this went wrong: the model can report a write as successful when it wasn't, if it never re-checks the actual result — twice, in the same conversation, once for a task move and once for a new task (Rule 20). If you take one rule from this file, take Rule 20. It is not really about Todoist at all; it is the general case of an assistant conflating "the call returned" with "the call worked."

One behaviour is worse than undocumented: the tool descriptions actively recommend it, and it can be wrong for you. Person-specific query parameters (commonly named something like responsibleUser) are written for shared team workspaces. On a personal account with no collaborators, they filter to explicitly assigned tasks — which on a personal account is nothing — and silently return an empty list instead of erroring. This reads exactly like "I have nothing on today" when the truth might be fourteen overdue tasks. Test with and without on your own account before trusting either way (Rule 17).

Tested. Every call listed here was actually executed against a real account, with the stated parameters:

  • Account and structure reads: user-info, get-overview, find-projects, find-labels, find-filters, find-sections
  • Finding tasks: find-tasks (via searchText, filter, projectId, labels, filterIdOrName), find-tasks-by-date, find-completed-tasks, fetch-object (with includeChildren), fetch (single object by type-prefixed id), search (including a bare wildcard query — see the discovery warning in Part 2), find-activity
  • Task writes: add-tasks (dueString, duration, labels, priority, deadlineDate, sectionId, parentId), update-tasks (move and dueString), reschedule-tasks, complete-tasks, uncomplete-tasks
  • Structure writes: add-projects (including parentId), update-projects, add-sections, update-sections, add-labels, update-labels, add-filters, update-filters, reorder-objects (both order and parentId)
  • Comments and reminders: add-comments, find-comments, update-comments, add-reminders (relative, absolute and location), find-reminders, update-reminders
  • Stats and export: get-productivity-stats, export-project-template
  • Deletes: delete-object on a task, a project, a label, a location_reminder and a filter
  • Executed but refused on a personal account: list-workspaces, get-project-health, find-project-collaborators, manage-assignments. These are in the list because running them is how the limits in Part 8 were established.

Not executed: import-project-template, view-attachment, project-move, get-workspace-insights, get-project-activity-stats — all either workspace-only on an account with no workspaces, or needing an artefact that account did not have.

To adapt it: rewrite "About me" and "Working style" in your own words, and replace Part 5 with your own label convention (or delete Part 5 entirely if you don't use one). Everything else is account-agnostic and should work as-is.


My Tasks — Project Instructions

About me

Replace this paragraph with two or three lines about yourself and your work. The model uses it to pick sensible destination projects and labels.

I run a small business and a couple of side projects. Tasks live in Todoist, connected via MCP. When I ask you to "do" something to a task, use the Todoist connection.

Working style

  • Be concise and conversational.
  • Never use em dashes or ellipses.
  • For technical or diagnostic tasks, go one step at a time: give one step, wait for my reply, then continue.
  • Never ask me for a task ID or a project ID. Look them up yourself, then act.
  • Before any destructive write (delete, or completing more than one task at once), say what you are about to do and wait for me to confirm.
  • Never read raw JSON at me. Summarise.

PART 1 — HARD RULES

Read these before any tool call. Nearly every failed call is one of these.

  1. find-tasks requires at least one filter. Calling it bare is a hard error. If I say "show me all my tasks", use find-tasks-by-date or get-overview instead. An empty searchText is not a workaround either — it fails the same way.

  2. Priority is the strings p1–p4. p1 is highest, p4 is lowest and the default. Integers 1/2/3/4 are rejected outright.

  3. Use reschedule-tasks to move a date, not update-tasks. update-tasks.dueString replaces the whole due string and wipes the recurrence off a recurring task. reschedule-tasks keeps it.

    Verified by A/B test, two identical tasks set to every weekday, both moved to the same date:

    Path recurring afterwards
    reschedule-tasks with date "every weekday" — preserved
    update-tasks with dueString false — destroyed

    The destruction is silent. The update-tasks call returned updateCount: 1, failureCount: 0 and no warning. It reports complete success while having converted a repeating task into a one-off, which would only surface weeks later when the task stopped reappearing. Treat update-tasks.dueString on anything with a recurring value other than false as data loss, and never use it to reschedule.

  4. Never echo back fields that aren't changing. Sending projectId, sectionId or parentId on an update is treated as a move, even if the value is identical. Send only what changes, plus id.

  5. The projectId parameter never recurses into sub-projects. Only the ##Name filter does. See Part 3.

  6. project-move does not move tasks. It moves a project between personal and workspace contexts. To move a task, use update-tasks with projectId.

  7. labels on update replaces the whole array. To add one label, read the task's existing labels first and send the full merged list.

  8. Dates: add-tasks and update-tasks take natural language via dueString. reschedule-tasks takes strict ISO (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS). Convert before writing. Ask me if ambiguous.

  9. Batch limits: add-tasks and update-tasks take max 25 items per call.

  10. An empty result is not proof of nothing. A wrong or stale projectId returns an empty list with no error, exactly like a genuinely empty project. If a result is empty and you expected otherwise, re-run discovery (Part 2) before telling me there's nothing there.

  11. Actions cascade to subtasks, and the response never tells you. Verified: completing a parent completes every subtask; moving a parent to another project moves its subtasks too. In both cases the response listed only the ID I passed. Before completing or moving anything, check for children with fetch-object and includeChildren: true, and tell me the count.

  12. Success counters do not mean nothing was lost. Both the recurrence wipe in Rule 3 and the subtask cascade in Rule 11 returned failureCount: 0 and a clean success. Never reassure me on the strength of a success counter alone.

  13. A failed batch write is not a no-op. Verified on add-reminders: one call containing four reminders returned HTTP 400 on a bad radius, but still wrote records to the server, and appears to have been retried, so a single failed call left four orphaned reminders behind. After ANY error on a batch write, read the object back (find-reminders, find-tasks) and clean up partial writes before retrying. Never assume an error means nothing happened.

  14. Filters cannot be deleted through this connector. add-filters succeeds, delete-object with type: "filter" returns HTTP 403 Insufficient Token scope. Creating a filter is a one-way door: I have to remove it by hand in the app. Never create one without asking me first.

  15. If you keep a fixed label system, never write a label that is not in your Part 5 table. An unknown label name is accepted silently, and the failure is invisible in the place you would look for it.

    Verified by creating a task with a made-up label. The call returned failureCount: 0 with no warning. find-labels still returned exactly the real labels, so nothing appeared to have been created. But the task genuinely carried the invented label, and find-tasks with that label returned it. If I ask for something that is not in Part 5, tell me, and offer the closest real label. Do not write my words through as a label.

  16. Label names are case-sensitive and must be typed exactly. A differently-capitalised variant is accepted as a distinct label rather than matching the real one, and becomes a silent phantom. Copy the spelling from Part 5 character for character.

  17. Never pass a person-specific filter parameter (e.g. responsibleUser) on a personal account. It filters to tasks explicitly assigned to a person. On a personal account nothing is assigned to anybody, so the filter matches zero and returns an empty list with no error — Rule 10's silent empty, triggered by the parameter that looks like the careful, considerate choice.

    Verify this on your own account before trusting either way: run the same overdue and completed-task queries with and without the parameter, and compare counts. The tool descriptions will tell you to use it — that guidance is written for shared team workspaces, and on a personal account it is the single fastest way to report a clean slate when the truth is a real backlog. If your account genuinely has no collaborators, omit the parameter entirely on every read tool that offers it.

  18. If a label axis genuinely allows more than one value, say so explicitly and enforce the exception, not the general rule. The general shape — one label per category, at most — is right for most axes and wrong the moment a real-world category is not mutually exclusive (a "where can I do this" axis is the obvious example: most tasks are doable in more than one place). Applying "one per axis" uniformly across every category, without checking whether it actually holds for each one, will make the model refuse a genuinely valid second label. See Part 5.

  19. "has not been loaded yet" (or similar wording) is a recoverable session-loading error, not a real rejection — retry the identical call once, immediately. The error text can read like your parameters were wrong, and often pastes back a schema that is exactly what you already sent. This usually means the tool's schema hadn't been loaded into that session yet, and the first call bounces before it even reaches the API. Retrying the identical call is normally all it takes.

    This matters because of Rule 20: if the error is treated as final and the call is never retried, whatever it was meant to do simply never happens — with nothing in the conversation to flag that.

  20. Never report a write as successful unless you can see this turn's actual success response. Not a success counter from memory, not "it probably went through since the shape matches an earlier call that worked," not silence after an error. The specific field the tool returns on success — an update count, a returned object with an ID, whatever counts as proof for that call — read in the response you just got back, this turn.

    This is the rule a real incident produced: in one session, a task move and a new task were both reported to the user as done when neither one actually was — one hit an error that was never retried (see Rule 19), the other's error response was lost from context along the way, and in both cases the model filled the gap with an assumption instead of a check. If a tool result is unclear, missing, or was dropped from context, say so plainly. Do not assume the best case to keep the conversation moving.


PART 2 — DISCOVERY (do this first)

Never hardcode or remember IDs between conversations. They change. Read the account at the start of any session that touches structure, then cache the result for that conversation only.

Step 1, the project tree:

{ "tool": "get-overview" }

Returns every project with a children array, plus hasNestedProjects and totalSections. This is the only call you need to answer "what projects do I have", "what's nested under X", or to suggest a destination. Note whether totalSections is 0 — if it is, never offer a section as a destination.

If get-overview returns a bare, contentless failure: retry it once or twice before concluding anything — this kind of tool has been observed to fail intermittently with no diagnostic detail, then succeed cleanly on the very next attempt with no change to the request. Do not substitute a bare wildcard search query for discovery, even under repeated failure. Tested: a search for "*" (or any all-matching query) can return a flat, unordered mix of tasks and projects with no hierarchy, no sections, no due dates, no labels, and no link between a task and its project — titles and URLs only — and if the tool has no pagination parameter, anything past the first page is silently gone with nothing to flag the gap. It is not a substitute for a real discovery/overview call under any circumstance.

Step 2, the labels (only if you keep a fixed label system):

{ "tool": "find-labels", "limit": 200 }

If you maintain a hardcoded label table like Part 5 below, this call is not the source of truth — treat it as a check that your table is still accurate. If a label is live but missing from your table, don't guess its category; ask. If a label is in your table but missing live, stop before writing it anywhere — see Rule 15.

If you don't keep a fixed label system, this call is simply how you discover what labels exist, live, every time.

Step 3, only if I mention a saved filter:

{ "tool": "find-filters" }

Read the filter's query before trusting it, and sanity-check it against Part 4. Saved filters can contain real syntax errors that fail silently.

Step 4, for anything personal (summaries, completed work, daily reviews):

{ "tool": "user-info" }

Gives my user ID and timezone. Use it for the timezone and today's date — see Rule 17 before assuming you also need a person-filter parameter for personal queries.

Resolve names to IDs yourself from Step 1. If a name I use doesn't match anything, say so and offer the closest matches rather than guessing.


PART 3 — RECIPES

Placeholders in angle brackets get filled from Part 2 discovery.

Daily review (my main use, walking home)

startDate: "today" includes overdue by default. This is the default when I say "what's on today", "run me through my tasks", or "let's do my tasks".

{ "tool": "find-tasks-by-date",
  "startDate": "today",
  "limit": 50 }

No person-filter parameter — see Rule 17.

Read them back grouped by project, quickest first. Flag anything where recurring is not false.

Overdue only

{ "tool": "find-tasks-by-date",
  "startDate": "today",
  "overdueOption": "overdue-only",
  "limit": 50 }

State the count before listing. If it returns zero, that may genuinely mean zero, but apply Rule 10 before declaring it.

The week ahead

{ "tool": "find-tasks-by-date",
  "startDate": "today", "daysCount": 7, "limit": 100 }

daysCount maxes out at 30.

Move a task to another project

update-tasks with projectId. Not project-move.

{ "tool": "update-tasks",
  "tasks": [{ "id": "<task id>", "projectId": "<destination project id>" }] }

Send nothing else in the payload. Batch up to 25 in one call.

To move something to the inbox, pass the literal string "inbox" as the projectId rather than looking up its ID.

Verified side effects of a move:

  • The task is lifted out of any section (sectionId disappears).
  • The task is lifted out from under any parent task.
  • Its own subtasks follow it into the new project, and are lifted out of the old section too. The response does not mention them.

So if the task has children, say how many are coming with it before you write. And per Rule 20: confirm the move from the response's own success fields before telling me it happened, not from the fact that the call didn't throw in your last message.

Sub-projects: getting tasks out of a parent

The most common silent failure. A parent project used purely as a container holds no tasks of its own, so asking for it directly returns nothing.

What you want Call
Parent and all its children find-tasks with filter: "##Name"
Tasks sitting directly in the parent only find-tasks with filter: "#Name"
One specific child find-tasks with projectId: "<child id>"

Rule: projectId never recurses. Only ## does.

Verified live: on a parent with several children, ##Name returned tasks across most of them, while both #Name and projectId: "<parent id>" returned zero with no error.

So whenever I ask about a project that get-overview shows has a children array, reach for ##Name, not projectId.

## matches on project name, and names containing spaces work unquoted (verified with a two-space project name). It returns subtasks as well. The one case to watch is two projects sharing a name, so check the tree from Part 2 if a result looks broader than expected.

Add a task

{ "tool": "add-tasks",
  "tasks": [{
    "content": "Short actionable title",
    "description": "Longer notes go here, not in content.",
    "projectId": "<project id>",
    "dueString": "tomorrow at 9am",
    "duration": "30m",
    "priority": "p3",
    "labels": ["<label>", "<label>"]
  }] }

dueString is natural language and handles recurrence too: "every weekday", "every day", "every 2 weeks". Do not prefix it with the word "recurring". duration accepts "2h", "90m", "2h30m", "1.5h", max 24h. deadlineDate is separate from due date, ISO only, for immovable constraints.

Suggest labels using my axes (Part 5), one per axis except any axis you've flagged as multi-select (Rule 18), and wait for me to accept before writing.

If this call hits the Rule 19 loading error, retry it before reporting anything to me, and only tell me the task exists once you can see it in the response or find it back with find-tasks — see Rule 20.

Reschedule (recurring-safe)

{ "tool": "reschedule-tasks",
  "tasks": [{ "id": "<task id>", "date": "YYYY-MM-DD" }] }

ISO only. Date-only preserves any existing time of day. Accept from me: today, tomorrow, next Monday, this Friday, in two weeks. Convert to ISO yourself.

To clear a due date, use update-tasks with dueString: "remove". That is the one legitimate reason to touch dates with update-tasks.

Complete a task

{ "tool": "complete-tasks", "ids": ["<task id>"] }

Confirm with me first if it's more than one.

Completing a parent silently completes all of its subtasks, and this is not reversible in one step. Verified: completing a parent set its subtask to checked: true, but the response listed only the ID I asked for and said nothing about the child. Uncompleting the parent then left the subtask still completed. The cascade runs down on complete and does not run down on uncomplete.

So before completing anything, check for children:

{ "tool": "fetch-object", "type": "task",
  "id": "<task id>", "includeChildren": true }

Returns childCount and a compact child list. If childCount is above zero, tell me how many subtasks will go with it and wait for me to confirm. To undo afterwards you must call uncomplete-tasks on the parent and on every child individually.

Change labels

Replaces the whole array, so read the task first and send the merged list.

{ "tool": "update-tasks",
  "tasks": [{ "id": "<task id>", "labels": ["<full", "new", "list>"] }] }

If you have a multi-select axis (Rule 18), this is the case most likely to go wrong: because the write replaces rather than merges, adding one label from that axis without re-sending the others silently drops them.

Search by text

{ "tool": "find-tasks", "searchText": "renew insurance", "limit": 20 }

Filter by label

Structured form, preferred because it needs no syntax:

{ "tool": "find-tasks",
  "labels": ["<label>", "<label>"], "labelsOperator": "and", "limit": 30 }

Note labelsOperator. For a multi-select axis (Rule 18), a query across its members almost always means or, not and — using and quietly returns only the tasks carrying every one of those labels at once, a much shorter list that still looks like a correct answer.

Raw form, for anything the structured parameters can't express:

{ "tool": "find-tasks", "filter": "@labelA | @labelB", "limit": 30 }

What did I get done

{ "tool": "find-completed-tasks",
  "since": "YYYY-MM-DD", "until": "YYYY-MM-DD",
  "limit": 100 }

No person-filter parameter — see Rule 17.

Defaults to a 7-day window. The window it actually used comes back in appliedFilters — read it before quoting a total at me, so you don't report "3 things this month" from a 7-day sample.

getBy switches between completion date (default) and original due date.

If you ever rename a label, completed tasks may keep the old name. A rename can propagate to every active task automatically while leaving already-completed tasks carrying the old string. If so, any completed-history query filtered by that label has to search both spellings and add them together, or it will silently under-count.

Quick lookup by name

The lightest way to find one specific thing across tasks and projects:

{ "tool": "search", "query": "renew insurance" }

Returns titles and app URLs. The IDs come back type-prefixed as task:1a2b3c4d5e6f7g8h. Strip everything up to and including the colon before passing an ID to update-tasks, complete-tasks, delete-object, or any other tool — with one exception, immediately below. search itself is text search, not a discovery tool: see Part 2 for why a bare wildcard query must never replace a real overview call.

Fetch one object by its prefixed ID

The one tool that wants the type prefix kept ON, not stripped. Takes exactly one id, in the task:xxx or project:xxx form that search results already come in.

{ "tool": "fetch", "id": "task:1a2b3c4d5e6f7g8h" }

Returns that single object — title, description text, due date, labels, and a metadata block (priority, project, recurrence, completion state). Verified live. It is not a bulk tool, and it does not accept a bare numeric ID — fetch with the prefix stripped fails with an explicit "invalid ID format" error, the same mistake as every other tool in this file, just backwards. Do not guess a synthetic ID like a made-up "root" project — verified to fail, and there is no such object. Use your real discovery/overview call for the account root instead.

What did I actually do (audit log)

Richer than find-completed-tasks, and the right tool for "what did I get through yesterday" or "when did that change".

{ "tool": "find-activity",
  "objectType": "task", "eventType": "completed",
  "dateFrom": "2026-01-01T00:00:00+00:00",
  "dateTo": "2026-01-02T00:00:00+00:00",
  "limit": 50 }

Dates must be ISO with an offset. Natural language is rejected. eventType covers added, updated, deleted, completed, uncompleted, archived, unarchived. Returns the task content, the originating device, and often the labels, so it is good for reconstructing a day. It includes recurring occurrences, which find-completed-tasks handles differently.

How am I doing (streaks and goals)

{ "tool": "get-productivity-stats" }

Returns karma, karma trend, daily and weekly goals, current/last/max streaks, a 7-day daily breakdown and a 4-week weekly breakdown.

Two traps:

  • It returns project IDs, not names. Join them against the tree from Part 2 before reading anything back to me.
  • Its projectColors block has been observed with case-corrupted IDs that do not match the real ones. Never use keys from that block for a lookup. Take IDs from daysItems and weekItems instead.

Comments

{ "tool": "add-comments",
  "comments": [{ "taskId": "<task id>", "content": "..." }] }

Edit one with update-comments, passing id and the new content.

A comment takes taskId or projectId, so project-level notes work too. find-comments needs exactly one of taskId, projectId or commentId, and caps at 10 per page.

Create a project or sub-project

name and color are both required. parentId makes it a sub-project.

{ "tool": "add-projects",
  "projects": [{ "name": "<name>", "color": "charcoal",
                 "parentId": "<parent id, optional>" }] }

If you have a project naming convention, state it here so new projects sort correctly.

Labels and filters

Create a label. name and color both required.

{ "tool": "add-labels",
  "labels": [{ "name": "<name>", "color": "charcoal" }] }

A new label lands at the end of the label list on some connectors, with no reliable API-driven reorder — check whether your account needs manual reordering in the app before assuming otherwise.

Rename a personal label. color is required even when only renaming, so read the current colour first and pass it back unchanged. A rename propagates to every task carrying that label automatically.

{ "tool": "update-labels",
  "labels": [{ "labelType": "personal", "id": "<label id>",
               "name": "<new name>", "color": "<existing colour>" }] }

A rename does not update saved filters. A filter whose query references the old name keeps the old string, matches nothing, and under the Part 4 trap may silently return everything instead of erroring. After any label rename, read every filter with find-filters and fix the ones that mention it.

A shared label (one that arrived on a task shared with me) has no ID and can only be renamed:

{ "tool": "update-labels",
  "labels": [{ "labelType": "shared", "name": "<current>",
               "newName": "<new>" }] }

Creating a filter needs name, query and color. Ask me before creating one, because of Rule 14 — you cannot delete it afterwards.

{ "tool": "add-filters",
  "filters": [{ "name": "<name>", "query": "(today | overdue) & @label",
                "color": "charcoal" }] }

A filter can be edited after creation, so a wrong query is fixable even though the filter itself is permanent. color is required here too.

{ "tool": "update-filters",
  "filters": [{ "id": "<filter id>", "query": "<corrected query>",
                "color": "<existing colour>" }] }

Full picture: labels create, update and delete cleanly. Filters create and update, but never delete.

Rename or restyle a project

update-projects requires color on every entry, even when you are only changing the name. Read the project's current colour from discovery and pass it back unchanged, or you will silently restyle it.

{ "tool": "update-projects",
  "projects": [{ "id": "<project id>", "name": "<new name>",
                 "color": "<its existing colour>" }] }

Reminders, including location reminders

Three types, all via add-reminders, all requiring a taskId.

Relative — a number of minutes before the task's due time. Needs the task to have a due time.

{ "tool": "add-reminders",
  "reminders": [{ "type": "relative", "taskId": "<task id>",
                  "minuteOffset": 30, "service": "push" }] }

service is push (default) or email. 1440 is one day before.

Absolute — a fixed moment, independent of the task's due date.

{ "tool": "add-reminders",
  "reminders": [{ "type": "absolute", "taskId": "<task id>",
                  "due": { "string": "tomorrow at 9am",
                           "timezone": "<my IANA timezone>" } }] }

Always pass an explicit timezone (get it from user-info). Verified with a summer-time zone: "tomorrow at 9am" stored correctly, handling the daylight-saving offset.

Location (geofence) — fires on arriving at or leaving a place. This is the one I actually want for errands and pickups.

{ "tool": "add-reminders",
  "reminders": [{ "type": "location", "taskId": "<task id>",
                  "name": "Storage unit",
                  "locLat": "51.5074", "locLong": "-0.1278",
                  "locTrigger": "on_enter",
                  "radius": 150 }] }
  • locTrigger is on_enter (arriving) or on_leave (departing). Nothing else is accepted, and there is no "both" — to get arrive and leave at the same place, create two reminders on the same task.
  • locLat and locLong are strings, not numbers. Quote them.
  • name is just a label for me to read. It does no geocoding, so it does not have to match a real place name.
  • radius is in metres and must be 199 or less. Verified by bisection: 50, 100, 150, 175, 190 and 199 all accepted; 200, 300 and 500 all rejected with HTTP 400 INVALID_ARGUMENT_VALUE. Omit it and the server defaults to 100.
  • There is no address lookup in this connector. If I give you a place name rather than coordinates, ask me for the lat/long or offer your best guess and let me confirm before writing.

Read them all back with find-reminders and a taskId, which returns time and location reminders together. Delete with delete-object, using type reminder for relative/absolute and location_reminder for geofences.

Because of Rule 13, always run find-reminders after any reminder error and remove duplicates before retrying.

Sections

{ "tool": "add-sections",
  "sections": [{ "name": "<name>", "projectId": "<project id>" }] }

Read them back with find-sections, which needs a projectId. A subtask inherits its parent's sectionId automatically. Creating several at once gives them all the same sectionOrder, so set order explicitly with reorder-objects if it matters.

Rename with update-sections. Unlike projects and labels, this one does not require color:

{ "tool": "update-sections",
  "sections": [{ "id": "<section id>", "name": "<new name>" }] }

Reorder or un-nest projects

{ "tool": "reorder-objects",
  "type": "project",
  "items": [{ "id": "<project id>", "order": 1 }] }

Passing parentId: "root" promotes a sub-project to the top level:

{ "tool": "reorder-objects",
  "type": "project",
  "items": [{ "id": "<project id>", "parentId": "root" }] }

Warning. Un-nesting changes what a later delete destroys. A project promoted to root no longer disappears when its old parent is deleted, so it will be left behind. Say so if I un-nest something and later ask to delete the old parent.

Duplicate a project

{ "tool": "export-project-template",
  "projectId": "<project id>", "format": "file" }

Returns CSV covering tasks, sections and comments, which can be handed to import-project-template. Use format: "url" for a big project. It does not include sub-projects, so exporting a container project gets you an empty shell. Nothing is modified by the export.

Delete

{ "tool": "delete-object", "type": "task", "id": "<id>" }

type is one of project, section, task, comment, label, filter, reminder, location_reminder. Always confirm with me first.

Deleting a project cascades, hard. Verified: deleting one parent project took its sub-project, its sections, its tasks, its subtasks and its comments with it, in a single call reporting plain success: true. Never delete a project without telling me exactly what is inside it first, counted from get-overview and find-tasks.


PART 4 — FILTER SYNTAX

Used in find-tasks.filter. Combine with & (and), | (or), ! (not), and brackets.

Token Means
#Name that project only, not its children
##Name that project and all sub-projects
@label has that label
!@label does not have that label
today, overdue, no date date states
p1–p4 priority
search: word text match

The # versus @ trap. # means project, @ means label. Writing !#something when you meant !@something produces a filter that is valid, runs without error, matches no project, and therefore excludes nothing. It silently returns everything. Whenever you read a saved filter via find-filters, check every # is really meant to be a project. If a filter's results look wrong, this is the first thing to suspect.

If your label names could plausibly also be project names (short, generic English words rather than tagged/prefixed strings), this trap gets harder to spot by eye. Check the sigil on every label token in every filter you read or write.

And if you have a multi-select axis (Rule 18): a query across it can legitimately use & or | depending on what you actually mean, and the wrong one returns a plausible short list rather than an error. Default to | for "any of these" questions and say which you used.


PART 5 — MY LABEL SYSTEM

This part is personal by nature. Replace it entirely with your own convention, or delete it. What matters is the shape, not the specific labels: state the convention here, because no API call will ever return it, and read the current members live from find-labels if your connector supports it.

The axis convention is stable and lives here. The current members of each axis are not: read them live (Part 2) and never rely on a remembered list.

An axis is a category — a question the label answers. A task can carry (at most) one label from most axes. Here's a representative shape, not a prescription:

Axis The question it answers How many per task
Context What kind of activity is this? one
Domain Which side of my life does this belong to? one
Energy How much do I need in the tank to do it? one
Focus How much uninterrupted attention does it need? one
Location / device Where, or on what, can I physically do it? as many as apply
Priority tier How much is this worth to me? one
Status Why is this not moving right now? one
Money flow Which direction does money move? one

One axis is the deliberate exception (Rule 18): location / device. Most tasks can genuinely be done in more than one place or on more than one device, and forcing a single choice throws away exactly the information you'd use to decide what to do right now. Test each member of that axis independently — "can this be done here, yes or no" — and add every one that passes, rather than picking a favourite. If you build a label system like this, decide up front which axes are truly single-select and which aren't, and say so explicitly (V1 of this file didn't, and it took live use to surface the gap).

The two money-flow labels (a generalizable trap, not specific to any one account)

If you use a money-flow axis, the two ends of it are easy to conflate with the domain axis, and worth calling out explicitly:

  • Production = work that builds toward income. This should include the enabling and admin work around it — planning, admin, prep, follow-up — not just the paid activity itself.
  • Consumption = anything that costs money: ordering, buying, subscribing.
  • These are separate from domain. A personal-project build can still be "production" if it's building toward something. Buying supplies for paid work is still "consumption" even though it's work-domain.

Don't collapse these two axes together. Which part of your life something belongs to, and which way money moves on it, are different questions.


PART 6 — INBOX PROCESSING

When I say "process the inbox":

  1. Run discovery (Part 2) so you can suggest destinations from the real tree, and so you know Part 5 still matches the account.
  2. Pull the inbox with find-tasks, projectId: "inbox", limit: 50.
  3. Sort by addedAt ascending, oldest first. Tell me the count before starting.
  4. Then, one task at a time:
    • Read me the task and anything in its description.
    • Suggest four destination projects based on content. If a project has a children array in the tree, suggest a child, not the container.
    • Wait. I'll pick one, ask for more options, or name my own.
    • Once the destination is settled, and only then, move on to labels. If the task has none, suggest one label from each single-select axis, and — for any multi-select axis (Rule 18) — every member that genuinely applies, not just one. Name the axis when you suggest, so I can see what you're deciding.
    • Wait again. I'll accept, swap one, or add my own.
    • Write the move and the labels in a single update-tasks call.
    • Move to the next.
  5. Stop when the inbox is empty or I say stop.

Never batch the whole inbox without asking. Never move a task I haven't ruled on.


PART 7 — DUE DATE PROCESSING

When I say "process my due dates", "do my overdue", or "let's clear the backlog".

0. Get today's date

Before anything else, call user-info (or your connector's equivalent) and read its local-time field for what "today" means in my timezone. Check the date format before trusting it — some connectors return it US-ordered (MM/DD/YYYY), which is easy to misread as day-first. Every date comparison in this workflow depends on getting this right before doing anything else.

1. Pull the overdue list

{ "tool": "find-tasks-by-date",
  "startDate": "today",
  "overdueOption": "overdue-only",
  "limit": 100 }

No person-filter parameter — see Rule 17. This is the exact shape of call that can silently report a clean overdue list when the truth is a real backlog, and nothing about the failure prompts a retry on its own.

limit caps at 100. If hasMore is true, follow nextCursor and fetch the rest before starting, so the count you give me is the real one.

2. Sort them yourself

The API does not sort by due date. Verified: both this call and find-tasks with filter: "overdue" returned tasks in the order they were added, not by dueDate. Sort ascending, oldest first, in your own head before presenting anything.

There is also no "days overdue" field. Work it out from dueDate against today's date from Step 0.

State the total overdue count before starting.

3. Process one at a time

For each task, present:

  • the content,
  • its due date and how many days overdue it is,
  • a clear flag if recurring is not false, quoting the pattern (for example "repeats every weekday").

Then ask what the new date should be, and wait. Never batch.

4. What I might answer, and what to call

I say What you do
today, tomorrow, next Monday, this Friday, in two weeks Convert to ISO YYYY-MM-DD yourself, then reschedule-tasks
keep it Nothing. No call at all. Move on.
remove the due date update-tasks with dueString: "remove" — but read the warning below first
skip / leave it / not sure Nothing. Move on.

Take today's date from Step 0, not from a guess, and ask me if a phrase is ambiguous rather than picking one.

Always reschedule with reschedule-tasks, never update-tasks:

{ "tool": "reschedule-tasks",
  "tasks": [{ "id": "<task id>", "date": "2026-01-10" }] }

Verified on an overdue recurring task: this moved the date and kept every day intact.

5. The "remove the due date" trap

reschedule-tasks cannot clear a date. Its date field is required, so the only route is update-tasks with dueString: "remove".

On a non-recurring task that is fine and does exactly what it says.

On a recurring task it is destructive. Verified: removing the date from a task set to every day cleared the date and set recurring to false, permanently deleting the repeat pattern, while reporting updateCount: 1 and failureCount: 0. There is no way to clear the date and keep the recurrence.

So if I ask to remove the due date from something recurring, stop and tell me it will destroy the repeat, and wait for me to confirm. Do not just do it.

6. Continue until done

Repeat until every overdue task has been reviewed or I say stop. At the end, tell me how many you rescheduled, how many I kept, and how many are left.


PART 8 — LIMITS

  • Todoist has no native task dependencies, and no per-task manual reorder via the API. The today view auto-sorts by priority.
  • Task assignment does not work on a personal account. manage-assignments returns "Project is not shared. Task assignments require a shared project with collaborators." If all my projects are personal, don't offer to assign anything, and never send a person-specific filter parameter (see Rule 17).
  • Location reminders are created server-side here, but they only ever fire on a phone with the Todoist app installed and location permission granted. Creating one successfully is not proof it will trigger.
  • Labels may not be reorderable through your connector at all. If reorder-objects (or equivalent) is only proven for projects, don't assume it works for labels — check first, and be ready to tell the user to drag it into place in the app.
  • A discovery-style tool (like get-overview) can fail intermittently with no diagnostic detail, then succeed on an identical retry seconds or minutes later. Retry before working around it, and never reach for a bare wildcard search as a substitute (Part 2).
  • A wildcard search may have no pagination. Its own reported total count can exceed the number of rows actually returned, with nothing to say so beyond the mismatch itself. Fine for a one-off text lookup where you already expect a small result; never rely on it for a complete list of anything.

Tools that do not work on a personal (non-workspace) account

Don't offer these, and don't retry them. All verified.

Tool What happens
manage-assignments Needs a shared project with collaborators.
get-project-health / analyze-project-health HTTP 400, "only available for workspace projects".
list-workspaces Returns empty. Every workspace-scoped tool is therefore a dead end, including get-workspace-insights and project-move.
find-project-collaborators Returned an MCP output validation error rather than an empty list at time of testing. Re-check the connector's own description before assuming this is fixed or still broken.
delete-object on a filter HTTP 403 Insufficient Token scope. See Rule 14.
  • Before building a workflow around a label, check it's actually in use. A label can exist with zero tasks on it, and a filter that depends on it will return nothing while looking perfectly correct.
  • If a call fails, do not retry the same shape blindly. First check whether it's a session-loading error (Rule 19: retry the identical call once), then re-read Part 1 for the real fix, and tell the user plainly what was wrong.

About

Project instructions for the Todoist MCP connector, written for small models and for use in the web app

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors