Personal GTD: Gmail → Todoist → Calendar time-blocking
Starred mail becomes a task, and every task over 30 minutes gets a block on your calendar.
A single-player workflow. Starring an email turns it into a Todoist task with the thread link in the description, so your inbox stops being a to-do list. Then, each evening, tasks due tomorrow that are estimated over 30 minutes get real blocks on your calendar in the gaps between meetings — because a task list with no time attached is a wish list.
How it flows
- 01
You star an email
One gesture, works on every Gmail client.
- 02
Poller picks it up within 5 minutes
Query excludes anything already labelled Captured, so it is safe to run often.
- 03
Task created in Todoist
Subject as the content, thread permalink in the description, a due string and a duration.
- 04
Message labelled Captured
The star stays — the label is what stops a second capture.
- 05
Evening pass blocks tomorrow
Tasks over 30 minutes get placed in real gaps as focus-time events, at most three a day.
- 06
Completed work releases its block
Stale focus blocks are deleted, matched back to their task by the URL in the description, so the calendar stays believable.
Set up each app
Work through these in order — later apps usually need a token or an id from an earlier one.
Gmail
Star is the capture gesture
- 01
Use starring as the only trigger
Poll every 5 minutes with the query below. One gesture, available on every Gmail client including the phone — no add-on, no forwarding address to remember. Polling is a deliberate trade: it keeps setup to a single token, where push would want a Cloud project and Pub/Sub. If five minutes is too slow for you, the recruiting workflow in this collection shows the push route.
Gmail search queryis:starred -label:Captured newer_than:14d - 02
Label after capture, do not unstar
Add the Captured label with users.messages.modify rather than removing the star. Unstarring fights your own muscle memory; the label is what makes the query idempotent. This is the one call that needs gmail.modify — gmail.readonly is enough to find the starred mail but 403s on the label write.
- 03
Keep a deep link to the thread
Build the permalink from the thread id so the task can take you straight back to the mail. Google documents no URL scheme for this, so treat the form below as convention rather than contract. The part that will bite you is /u/0: it means whichever account signed in first, not a particular mailbox. If you run two Google accounts, hard-code the right index or pass authuser.
Thread permalink — undocumented formhttps://mail.google.com/mail/u/0/#all/18f2c9a4b7e1 https://mail.google.com/mail/u/1/#all/18f2c9a4b7e1 https://mail.google.com/mail/?authuser=you@acme.com#all/18f2c9a4b7e1
Todoist
The single list of what is next
- 01
Get your API token
Todoist → Settings → Integrations → Developer → copy the API token. Create one project called Inbox Zero and note its id from GET /api/v1/projects, which is cursor-paginated. Use the v1 path and nothing older: REST v2 is not deprecated-but-working, it returns 410 Gone, and the unified v1 API replaced it.
- 02
Create the task with a due string
due_string accepts natural language and is parsed in your account timezone, which is far less error-prone than hand-building a date — a bare due_string creates a floating due date, so pass a fixed timezone if you travel. Use the project id v1 handed you: v1 ids are base32 strings, not the numeric ones v2 returned. On priority, take the numeric convention here as authoritative — 4 is P1/urgent in the UI and 1 is P4/natural, and Todoist's REST schema text says the opposite of its own Sync section. duration does nothing at all unless duration_unit rides along with it. Send X-Request-Id as Todoist's own examples do, but the documented idempotency guarantee is on the Sync API's command uuid, so treat it as best-effort and dedupe on the Gmail message id too.
POST /api/v1/taskscurl -X POST https://api.todoist.com/api/v1/tasks \ -H "Authorization: Bearer $TODOIST_TOKEN" \ -H "Content-Type: application/json" \ -H "X-Request-Id: gmail-18f2c9a4b7e1" \ -d '{ "content": "Reply to Dana about the Q3 contract", "description": "https://mail.google.com/mail/u/0/#all/18f2c9a4b7e1", "project_id": "6X6WMMqgq2PWxjCX", "labels": ["from-email"], "priority": 3, "due_string": "tomorrow at 9am", "duration": 45, "duration_unit": "minute" }' # The response carries url — https://app.todoist.com/app/task/6XR4GqQQCW6Gv9h4 — # and that is what the calendar block links to. Do not compose it yourself: the # old showTask?id= form was removed and now 404s. - 03
Set a duration, always
Default to 30 minutes when the email gives you nothing to go on. The duration field is what the calendar step reads, and it is silently void without duration_unit beside it. Get either wrong and nothing gets blocked — the second half of this workflow just quietly does nothing.
Google Calendar
Turns intentions into defended time
- 01
Read tomorrow's tasks and your free gaps
Run at 18:00. GET /api/v1/tasks/filter?query=tomorrow — v1 dropped the filter parameter from the task list and gave filtering its own endpoint, which takes one query and does not accept comma-joined filters. It is cursor-paginated, so page through next_cursor before concluding tomorrow has nothing over 30 minutes. Keep the tasks with duration over 30 minutes, then list calendar events for tomorrow to find the gaps between 09:00 and 17:00.
Blocking ruleswork window 09:00-17:00 local min gap to use 45 min (30 min task + 15 min buffer) max blocks/day 3 # beyond this the day is fiction skip days with >4h of existing meetings order priority desc, then longest task first - 02
Insert it as focus time, not a plain busy block
transparency "opaque" is already the default and only marks you busy — it declines nothing, so it does not actually stop a colleague booking over the block. Focus time is the feature that does: eventType "focusTime" with autoDeclineMode set to declineOnlyNewConflictingInvitations turns away conflicting invitations while it runs, and sets your chat status too. Primary calendar only, and never all-day. colorId 8 (graphite) keeps focus blocks visually distinct from real meetings. Worth knowing why this is custom at all: Todoist's own Calendar integration already mirrors dated tasks two-way, but it mirrors every one of them — this workflow exists for what it does not do, which is choosing which gap, capping the day at three blocks, and skipping meeting-heavy days.
POST /calendar/v3/calendars/primary/events{ "summary": "Focus — Reply to Dana about the Q3 contract", "description": "Todoist: https://app.todoist.com/app/task/6XR4GqQQCW6Gv9h4", "start": { "dateTime": "2026-04-09T09:00:00-04:00" }, "end": { "dateTime": "2026-04-09T09:45:00-04:00" }, "eventType": "focusTime", "focusTimeProperties": { "chatStatus": "doNotDisturb", "autoDeclineMode": "declineOnlyNewConflictingInvitations", "declineMessage": "In focus time — I will come back to you this afternoon." }, "transparency": "opaque", "colorId": "8", "reminders": { "useDefault": false } } - 03
Clean up what you did not do
At 17:30 delete future focus blocks whose Todoist task is already complete — GET /api/v1/tasks/completed/by_due_date is the list to check them against. The block carries the task URL in its description, so the task id parsed out of it is your join key; store the pair at write time if you would rather not parse. A calendar full of stale blocks is one you stop believing, and then the whole system is dead.