Summary
A task created with a due_string that contains a starting clause has the entire string stored verbatim as its recurrence rule. Completing the task then never advances it — every completion re-pins the same date. The identical recurrence without starting advances correctly.
Todoist's quick-add parser (the app's "Add task" box, and td task quickadd) handles the same text correctly: it consumes starting <date> into the due date and stores a clean recurrence rule (every! 2 weeks). So Todoist has two natural-language date parsers — the due_string field parser and the quick-add parser — and they disagree on starting.
This is server-side — it reproduces with raw REST, no td involved (see below). Filing it here because td task add --due is likely a common way to hit it. td task quickadd is not affected.
Impact
Silent. A recurring task created via the API looks completely normal; the breakage only surfaces the first time you complete it — the task doesn't move, and never will. Anything that creates recurring tasks programmatically — td task add --due, the SDK, direct REST, bulk imports/migrations — is exposed.
Reproduce — td CLI (v1.63.0)
$ td task add "repro-cli" --due "every! 2 weeks starting 2026-05-17"
$ td task view "repro-cli" --json | jq -c .due
{"isRecurring":true,"string":"every! 2 weeks starting 2026-05-17","date":"2026-05-17","timezone":null,"lang":"en"}
$ td task complete "repro-cli"
$ td task view "repro-cli" --json | jq -c .due
{"isRecurring":true,"string":"every! 2 weeks starting 2026-05-17","date":"2026-05-17","timezone":null,"lang":"en"}
# ^^^^^^^^^^^^^^^^^^ date UNCHANGED — did not advance
Control — td task quickadd parses the identical text correctly:
$ td task quickadd "repro-qa every! 2 weeks starting 2026-05-17"
$ td task view "repro-qa" --json | jq -c .due
{"isRecurring":true,"string":"every! 2 weeks","date":"2026-05-17","timezone":null,"lang":"en"}
# ^^^^^^^^^^^^^^^^ "starting" consumed into the date; clean recurrence rule stored
That task advances correctly on completion.
Reproduce — raw REST (no td; isolates it to the API)
TOKEN=... # a Todoist API token
TODAY=$(date +%F)
# CASE 1 — due_string WITH a "starting" clause
ID=$(curl -s -X POST https://api.todoist.com/api/v1/tasks \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"content\":\"repro-A\",\"due_string\":\"every! 2 weeks starting $TODAY\"}" | jq -r .id)
curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ID/close" -H "Authorization: Bearer $TOKEN"
curl -s "https://api.todoist.com/api/v1/tasks/$ID" -H "Authorization: Bearer $TOKEN" | jq -c .due
# => due date is UNCHANGED after completion
curl -s -X DELETE "https://api.todoist.com/api/v1/tasks/$ID" -H "Authorization: Bearer $TOKEN"
# CASE 2 — identical recurrence WITHOUT "starting" (control)
ID=$(curl -s -X POST https://api.todoist.com/api/v1/tasks \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"content":"repro-B","due_string":"every! 2 weeks"}' | jq -r .id)
curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ID/close" -H "Authorization: Bearer $TOKEN"
curl -s "https://api.todoist.com/api/v1/tasks/$ID" -H "Authorization: Bearer $TOKEN" | jq -c .due
# => due date advanced by 2 weeks
curl -s -X DELETE "https://api.todoist.com/api/v1/tasks/$ID" -H "Authorization: Bearer $TOKEN"
Observed (run 2026-05-17):
CASE 1 due_string sent: "every! 2 weeks starting 2026-05-17"
POST /tasks -> stored due.string = "every! 2 weeks starting 2026-05-17" due.date = 2026-05-17
POST .../close -> HTTP 204
GET /tasks/{id} -> due.string = "every! 2 weeks starting 2026-05-17" due.date = 2026-05-17 << UNCHANGED
CASE 2 due_string sent: "every! 2 weeks"
POST /tasks -> stored due.string = "every! 2 weeks" due.date = 2026-05-17
POST .../close -> HTTP 204
GET /tasks/{id} -> due.string = "every! 2 weeks" due.date = 2026-05-31 << advanced +2 weeks
Same endpoint, same auth, same completion call — the only variable is the starting clause.
Where the fault is
td task add --due "X" sends X verbatim as the task's due_string. Confirmed: the created task's due.string comes back byte-identical to the input — the CLI and SDK do not transform it.
- The raw-REST repro reproduces the bug with
td entirely out of the loop, so the fault is server-side: the due_string parser on POST /api/v1/tasks stores a starting clause inside the recurrence rule instead of resolving it into the due date.
- The quick-add parser (the app's Add-task box,
td task quickadd) resolves starting correctly. The two parsers should agree.
Expected
POST /api/v1/tasks with due_string: "every! 2 weeks starting <date>" should behave like quick-add: set the first due date to <date>, store the recurrence rule as every! 2 weeks, and advance normally on completion.
Workaround
Create the task with a bare recurrence, then position the first occurrence separately:
td task add "task" --due "every! 2 weeks" # created due today
td task reschedule "task" 2026-06-20 # moves the first occurrence; recurrence preserved
td task reschedule keeps the recurrence rule intact, and the task advances correctly on completion thereafter.
Versions
td 1.63.0 (@doist/todoist-cli)
- Endpoint:
POST https://api.todoist.com/api/v1/tasks
- Reproduces equally via direct REST with no
td in the path (above)
- macOS 26.5
Summary
A task created with a
due_stringthat contains astartingclause has the entire string stored verbatim as its recurrence rule. Completing the task then never advances it — every completion re-pins the same date. The identical recurrence withoutstartingadvances correctly.Todoist's quick-add parser (the app's "Add task" box, and
td task quickadd) handles the same text correctly: it consumesstarting <date>into the due date and stores a clean recurrence rule (every! 2 weeks). So Todoist has two natural-language date parsers — thedue_stringfield parser and the quick-add parser — and they disagree onstarting.This is server-side — it reproduces with raw REST, no
tdinvolved (see below). Filing it here becausetd task add --dueis likely a common way to hit it.td task quickaddis not affected.Impact
Silent. A recurring task created via the API looks completely normal; the breakage only surfaces the first time you complete it — the task doesn't move, and never will. Anything that creates recurring tasks programmatically —
td task add --due, the SDK, direct REST, bulk imports/migrations — is exposed.Reproduce —
tdCLI (v1.63.0)Control —
td task quickaddparses the identical text correctly:That task advances correctly on completion.
Reproduce — raw REST (no
td; isolates it to the API)Observed (run 2026-05-17):
Same endpoint, same auth, same completion call — the only variable is the
startingclause.Where the fault is
td task add --due "X"sendsXverbatim as the task'sdue_string. Confirmed: the created task'sdue.stringcomes back byte-identical to the input — the CLI and SDK do not transform it.tdentirely out of the loop, so the fault is server-side: thedue_stringparser onPOST /api/v1/tasksstores astartingclause inside the recurrence rule instead of resolving it into the due date.td task quickadd) resolvesstartingcorrectly. The two parsers should agree.Expected
POST /api/v1/taskswithdue_string: "every! 2 weeks starting <date>"should behave like quick-add: set the first due date to<date>, store the recurrence rule asevery! 2 weeks, and advance normally on completion.Workaround
Create the task with a bare recurrence, then position the first occurrence separately:
td task reschedulekeeps the recurrence rule intact, and the task advances correctly on completion thereafter.Versions
td1.63.0 (@doist/todoist-cli)POST https://api.todoist.com/api/v1/taskstdin the path (above)