Todoist — David's Setup

SkillProductivity

Use when David asks about tasks, Todoist, to-do lists, priorities, inbox, or anything related to task management. Handles reading, creating, updating, moving, completing, and deleting Todoist tasks using David's specific setup and rules.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Todoist — David's Setup skill

What this skill tells your AI

The instructions your AI receives, as published by matyasstoch/david-skills in skills/todoist/SKILL.md and read by ahel’s review.

API & Auth

  • API v1 only: https://api.todoist.com/api/v1/ — IDs are alphanumeric strings, lists are cursor-paginated (next_cursor), limit max 200.
  • Auth: $TODOIST_API_TOKEN from .env file. Load with set -a && source .env before any API call. Use set -a (auto-export) — a plain source .env does NOT export the var to child processes, so a Python/Node subprocess hits KeyError: 'TODOIST_API_TOKEN'.
  • Rate limit: REST ~1,000 requests / 15 min. Sync: ~50/min, ≤100 commands per batch request.

⚠️ SECRET-REDACTION TRAP — ROOT CAUSE + FIX ORDER (caused a 10+ call cascade on 29-05-2026)

Root cause: this environment's secret-redactor matches the token VALUE in any text the agent writes/echoes/logs. When a script file contains the expanded Bearer <token> literal, the redactor corrupts the line on write (eats from Bearer to EOL → dangling quote → unexpected EOF at runtime). String tricks treat the symptom. The real fix is to never materialize the token value in agent-written text. Fix order:

  1. Python SDK (preferred). pip install "todoist-api-python>=3.1,<4". Token read at runtime, header built inside the SDK — nothing for the redactor to match:
    import os
    from todoist_api_python.api import TodoistAPI
    api = TodoistAPI(os.environ["TODOIST_API_TOKEN"])
    for page in api.get_tasks(project_id="INBOX_PROJECT_ID", limit=200):
        for t in page: print(t.id, t.content)   # iterate pages; each page = 1 request
    
    Method map: tasks get_task(s)/filter_tasks/add_task/add_task_quick/update_task/complete_task/uncomplete_task/move_task/delete_task; projects/sections/labels/comments have parallel get/add/update/delete.
  2. Hosted MCP https://ai.todoist.net/mcp (OAuth, agent never sees the token) — use if MCP is wired up.
  3. curl with secret off argv-K configfile or ~/.netrc, never a typed Bearer <token> on the command line.
  4. Last resort (curl, token in script): split the keyword so the redactor pattern never forms — B="Bea""rer"; HDR="Authorization: $B $TODOIST_API_TOKEN"; curl -H "$HDR". NOTE: this env is aggressive enough that even a bare $TODOIST_API_TOKEN reference has corrupted writes — so prefer 1–3; treat curl as fallback only.

HARD BAN — sync token leak: NEVER dump/echo/log a raw /sync response. The full-sync payload contains user.token (the account's own API token) — printing it both leaks the secret and re-triggers the redaction corruption. Extract only the fields you need; strip user/token first.

David's Projects (ONLY fetch from these)

ProjectID
InboxINBOX_PROJECT_ID
PersonalPERSONAL_PROJECT_ID
BusinessBUSINESS_PROJECT_ID
WisdomWISDOM_PROJECT_ID
DavidDAVID_PROJECT_ID

Team / shared projects (NEVER include in David's task fetches): collaborator projects and shared household/admin projects. (Project list is live — re-fetch with GET /projects?limit=200 if names/IDs look stale.)

Assigning Tasks (shared projects only)

Assignees only work in shared projects — the assignee must be a collaborator on that project, and the project must be shared with their account. In a non-shared project the assign is ignored / reset to David's own uid.

  • REST/v1 write endpoints use assignee_id (integer user ID, or null to unassign). Set it on create (POST /tasks) or update (POST /tasks/{id}).
  • Sync API uses responsible_uid (same concept, different name). Read-only companions: assigned_by_uid, last_responsible_uid.
  • Get user IDs for collaborators: GET /projects/{project_id}/collaborators → returns each collaborator's id, name, email. Your own ID comes from the "Get user" endpoint.
  • Prefer the Python SDK (update_task(task_id, assignee_id=...)) to avoid the token-redaction trap.
# Assign a task to a collaborator on update
curl -s -X POST "https://api.todoist.com/api/v1/tasks/{id}" \
  -H "Authorization: Bearer $TODOIST_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"assignee_id": 123456789}'   # null to unassign

David's 4 main projects are Personal, Business, Wisdom, and David (Inbox excluded).

How David Uses Todoist

David uses the Todoist Inbox as a brain dump — notes, ideas, observations, and tasks all go in. During triage, non-actionable items (ideas, mantras, strategic thoughts, tasks not happening this week) get moved to notes/good-ideas.md. Todoist should only contain focused, actionable tasks.

Default Scope Rule

ALWAYS default to today + overdue. Never fetch future or undated tasks unless explicitly asked.

The word "all" in casual conversation ("show me all my P1 tasks") does NOT mean bypass the date filter. It still means today + overdue.

EXCEPTION: Inbox. Inbox tasks often have NO due date — it's a brain dump. When fetching Inbox tasks, fetch ALL tasks in the Inbox project regardless of due date. Do NOT apply the today+overdue date filter to Inbox. Fetch Inbox separately using project_id=INBOX_PROJECT_ID without any filter parameter.

Priority Mapping (Inverted)

UI PriorityAPI value
P1 (urgent/red)4
P2 (orange)3
P3 (blue)2
P4 (default)1

API number ≠ UI label. "priority": 4 in JSON = P1, NOT P4.

Reading Tasks

Use curl with pagination. Filter by David's 5 project IDs using jq. Always handle next_cursor.

CRITICAL: The Todoist API filter parameter is unreliable for date filtering — it leaks future-dated tasks. ALWAYS apply a client-side date filter with jq after fetching. Never trust the API filter alone.

CRITICAL: ALWAYS use a script file (Write to /tmp/todoist_*.sh, then bash /tmp/todoist_*.sh). NEVER run jq inline in zsh. zsh escapes != as \!= which breaks jq filters. This applies to ALL Todoist operations that use jq — fetching, filtering, formatting, counting by group, everything. No exceptions.

# Write this to /tmp/todoist_fetch.sh, then run with: bash /tmp/todoist_fetch.sh
#!/bin/bash
cd ~/path/to/workspace
set -a && source .env
TODAY=$(date +%Y-%m-%d)

# Fetch all pages
ALL_TASKS="[]"
CURSOR=""
while true; do
  FILTER=$(printf 'today | overdue' | jq -sRr @uri)
  URL="https://api.todoist.com/api/v1/tasks?filter=${FILTER}&limit=200"
  [ -n "$CURSOR" ] && URL="${URL}&cursor=${CURSOR}"

  RESPONSE=$(curl -s "$URL" -H "Authorization: Bearer $TODOIST_API_TOKEN")
  RESULTS=$(echo "$RESPONSE" | tr -d '\000-\037' | jq '.results')
  ALL_TASKS=$(echo "$ALL_TASKS $RESULTS" | jq -s 'add')
  CURSOR=$(echo "$RESPONSE" | tr -d '\000-\037' | jq -r '.next_cursor // empty')
  [ -z "$CURSOR" ] && break
done

# Filter to David's projects only + enforce today/overdue client-side
DAVIDS_PROJECTS='["INBOX_PROJECT_ID","PERSONAL_PROJECT_ID","BUSINESS_PROJECT_ID","WISDOM_PROJECT_ID","DAVID_PROJECT_ID"]'
echo "$ALL_TASKS" | jq --argjson p "$DAVIDS_PROJECTS" --arg today "$TODAY" \
  '[.[] | select(.project_id as $pid | $p | index($pid)) | select(.due.date != null) | select(.due.date <= $today)]' \
  > /tmp/todoist_tasks.json

echo "Total: $(jq 'length' /tmp/todoist_tasks.json) tasks"

Custom filters: replace the filter param. Always add the client-side date guard unless David explicitly asks for future tasks.

Counting Tasks

  • RIGHT: | jq 'length'
  • WRONG: | wc -l (counts JSON lines, not tasks)

Bulk Delete Loop — Trailing Newline Trap (29-05-2026)

When deleting a list of IDs, do NOT pipe a file into while read -r ID; do ... done < ids.txt. If the last ID has no trailing newline, read silently drops it — that task survives while everything looks successful. Happened on 29-05: 8/9 deleted, last one ("remember") skipped.

FIX: loop over a bash array or stream IDs straight from jq with a trailing newline guaranteed:

for ID in $(jq -r '.[].id' ids.json); do
  CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$BASE/tasks/$ID" -H "$HDR")
  echo "$ID -> $CODE"
done

Always run a final re-fetch + count (skill's verify step) to catch any survivor.

Write Operations (curl directly)

Use X-Request-Id: $(uuidgen) header on creates/updates to prevent duplicates.

# Create
curl -s -X POST "https://api.todoist.com/api/v1/tasks" \
  -H "Authorization: Bearer $TODOIST_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Request-Id: $(uuidgen)" \
  -d '{"content": "Task name", "project_id": "PROJECT_ID", "priority": 4}'

# Update (POST, not PATCH)
curl -s -X POST "https://api.todoist.com/api/v1/tasks/{id}" \
  -H "Authorization: Bearer $TODOIST_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"priority": 3}'

# Complete
curl -s -X POST "https://api.todoist.com/api/v1/tasks/{id}/close" \
  -H "Authorization: Bearer $TODOIST_API_TOKEN"

# Move (NEVER use update endpoint to move — it silently ignores project_id)
curl -s -X POST "https://api.todoist.com/api/v1/tasks/{id}/move" \
  -H "Authorization: Bearer $TODOIST_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"project_id": "TARGET_ID"}'

# Delete
curl -s -X DELETE "https://api.todoist.com/api/v1/tasks/{id}" \
  -H "Authorization: Bearer $TODOIST_API_TOKEN"

Recurring Task Gotchas

  • close on recurring task → rolls due date forward. Task stays alive.
  • DELETE on recurring → unreliable, may persist.
  • To permanently delete a recurring task, use Sync API: POST /api/v1/sync with {"commands": [{"type": "item_delete", "uuid": "<uuidgen>", "args": {"id": "TASK_ID"}}]}
  • To complete a recurring task forever: first clear recurrence ({"due_string": ""}), then close it.

Move Tasks

POST /api/v1/tasks/{id}/move with {"project_id": "TARGET_ID"}. Moving gives the task a NEW ID. Old ID becomes invalid. Match by content, not ID, when tracking.

Deprioritize Operation

Downgrade all priorities by one level. Process in this order to avoid double-downgrading:

  1. P3→P4 (API 2→1)
  2. P2→P3 (API 3→2)
  3. P1→P2 (API 4→3)

Scope: today + overdue only, David's 5 projects only. Log task IDs + before/after values. Confirm batch size first.

Stale ID Trap (CRITICAL)

Todoist task IDs go stale fast. IDs change when tasks are moved between projects. New tasks added between fetches have completely different IDs. The API returns 204 even when deleting an already-gone ID — so you get NO error signal.

Rule: ALWAYS re-fetch tasks immediately before ANY write/delete operation. Match tasks by content (exact text) to get the current ID, then operate on that fresh ID. Never reuse IDs from a fetch that happened more than a few seconds ago. This applies to deletes, moves, updates — everything.

Failure mode: you fetch IDs, time passes (user approves, you write to a file, etc.), then you delete using the old IDs. The API silently succeeds on stale IDs while the real tasks survive untouched.

Stale IDs — Critical

NEVER cache Todoist task IDs across operations. Task IDs change when tasks are moved between projects, and new tasks added between fetches will have completely different IDs than expected. The API returns 204 on DELETE even for already-deleted IDs — so stale deletes appear to succeed while the real tasks survive.

Rule: Always re-fetch tasks immediately before any write/delete operation. Match by content (task text) to get the current ID, then operate on that fresh ID. One fetch-then-act cycle per batch. No exceptions.

Bulk Operations Safety

  1. Confirm scope (date filter + project filter + count) before executing.
  2. Re-fetch fresh IDs immediately before executing — never use cached IDs.
  3. Log task IDs + before/after values at write-time.
  4. Execute in small batches with confirmation.
  5. Verify first batch before proceeding.

For full API reference, see api-reference.md.

Signals

GitHub stars
60
Forks
11
Last commit
Jun 2026
Advanced
Catalog kind
skill
Gateway key
todoist
Source
github.com/matyasstoch/david-skills