I paid for Todoist for years, and capture was never the problem. To type “call the roofer” takes four seconds in any app.
The problem was the four seconds after. Which project? Does it need a label? A due date, or does undated mean lost forever? That is the triage tax, and every item pays it. Pay the tax, and capture stops being frictionless. Skip the tax, and the inbox becomes a landfill, and one day you declare bankruptcy on it.

The other problem: my tasks lived in someone else’s database. The schema was not mine to query. The data sat next to no other system I own.
So I replaced it. A self-hosted backend, a front-end I built, and a small fast model that does the filing. I expected the model to be the interesting part. It was not.
The migration, and the proof that it took
The cutover took one day. I kept the subscription for three more weeks. A system that works and a system that has everything are not the same system.
The old projects came over intact first. Then I reorganized them to mirror the structure of my notes: 38 projects collapsed to 16. Most of those 38 were scaffolding. I created them with optimism and never used them. Empty projects are worse than no projects. They are plausible destinations, so items get filed into them and never seen again.
Three weeks later, I diffed all 53 open tasks in the frozen Todoist account against the new system’s full history. I fuzzy-matched titles across open and done tasks. Fifty were already there. I completed 17 of those in the new system while Todoist sat untouched. That is the real proof of cutover.
Three tasks never migrated. I recovered them by hand with their original descriptions. I verified that two recurring tasks rolled forward correctly. Then I cancelled the subscription.
If you migrate anything you depend on, do that diff. People skip that step. “It seems fine” is not a migration test.
Capture must be at least as good, or you will go back
A commercial task app is excellent at one thing: quick-add. A replacement with worse quick-add gets abandoned in a week.
So my quick-add box parses the same syntax, client-side. It parses dates (thursday, jul 31, in 2 days, 8/15), times, priorities p1–p4, #project, @label, and recurrence (every month, every 3 days). Live preview chips render under the input. They show what parsed and where the task will go, before you press Enter.
Three parsing decisions took real thought.
The parser rejects invalid input. It never guesses. 25:99, feb 30, and 13/45 produce nothing. A confident wrong date is worse than nothing. A parser that guesses is a parser you must double-check. That defeats the point.
The parser consumes only the last date expression. “draft friday post in 2 days” schedules for two days from now. The word “friday” stays in the title. Greedy date extraction mangles titles.
A #project files the task directly and skips the robot. When you know where an item goes, a wait for a classifier is worse than no classifier.
I also remapped the priority numbers, for display only. I type p1 and see p1 · urgent, because that is my muscle memory. The backend keeps its own inverted scale. So the API, the agents, and the raw admin UI stay untouched. Display conventions are cheap to bend. Data models are not.
When a robot files your tasks, you must see where they went
A triage service polls the inbox every 20 seconds. It classifies each new task: project, up to three labels, priority, and due date. I wrote about what makes that trustworthy. It explains every decision in a comment. It only fills gaps and never overrides what you typed. Your manual corrections become few-shot examples.
I underestimated the failure mode that automation creates. In Todoist, a misfiled task was my fault, and I remembered the action. When a model files a task, you have no memory of the decision. The task is somewhere you are not looking. You find out in three weeks. The roofer, meanwhile, remains uncalled.
The fix is not better classification. The fix is a surface where nothing can hide. A recent view lists everything captured in the last seven days, newest first, open or done. Each row shows the task’s current project. One glance answers “where did that go?” It is the cheapest feature in the app, and it is the reason I trust the expensive one.
Two supporting details. A low-confidence filing keeps a mechanical needs-review label instead of a silent park in the inbox. An unroutable task gets a flag, not endless retries. Every decision goes to a log file with its reasoning, in text I can read. “The model classified it” is not an explanation.
One hygiene rule for anyone who builds a correction-learning loop: prefix your test tasks and scrub them from the logs. I grade this system with deliberate junk. Without the scrub, my test misfiles become training examples. Then I teach the robot to repeat my tests.
Why it polls instead of webhooks
The backend has webhooks. They are unusable here, for a reason worth knowing.
The webhook delivery runs behind an SSRF guard that blocks internal addresses. That is correct behavior for a server that can fire user-configured URLs. It is fatal when the receiver is another container on the same box. That build has no allowlist. The webhooks also fire once, with no retry.
So triage polls, and reminders poll. Both are more reliable than the event system they replaced. Polling is unfashionable, and polling is fine. A 20-second loop over a local API costs nothing. It never silently drops a delivery.
The part I did not plan: my agents file tickets
The task system is mine, so I could put an MCP server in front of it. Now the AI agents I work with all day talk to the same task list I do. Not a parallel to-do file. Not scratch notes in a chat log. The real list, with the real projects.
That started as convenience. I say “file that as a task”. The task lands, gets triaged, and appears in tomorrow’s morning email. Then it turned into a work-handoff protocol between machines. A small company now operates out of my closet.
Each agent has a real user identity. In a shared project, any agent can file a task for another agent, claim it, report progress, and hand it back. Every action points to a specific machine.
The interesting engineering is a race-safe claim without a lock. Two agents can try to claim the same task at the same time. An agent claims a task when it writes a comment. Comment IDs only increase, so every claim attempt gets a total order for free. The lowest ID wins. Every other agent reads the log and backs off.
There is no distributed lock, no lease, and no coordinator. There is only an append-only log that the database already maintains. I tested it under real write contention: five races, five clean single-owner results.
The other design rule I like: zero noise by construction. Agent tasks carry no due dates and no reminders. That project is excluded from triage routing, the counts, the digest sweeps, the morning email, and the vault export. A robot work queue that pushes notifications at me would last a week. I read that project when I want the story. It never reads me.
The full protocol is in the repo as a runbook. The test bar holds 27 scenarios, run twice, with the live race and the noise regressions. If you build agent coordination on a task system, write the bar first. The failure modes are all concurrency, and all silent.
The bridges to everything else
Two more connections exist only because I own the data.
Open tasks export to my notes vault every 30 minutes as a plain markdown file, grouped by project. That file rides the vault’s git pipeline into my embeddings index. So my assistant can search my task list next to my notes. The MCP is the real-time path. The export is the “what was I working on in June” path.
The reverse also works. A note with tide: true in its frontmatter gets exactly one task that points back at it: ”📋 {title} — N open items”. The task auto-completes when all checkboxes in the note are ticked. This is opt-in on purpose. The vault contains about 1,385 embedded checkboxes, and almost none of them are todos. The checklist stays in the note, where it has context. The task exists only to remind me that the note exists.
What I warn you about
The backend image tracked :latest. One morning, it silently upgraded a minor version and changed session behavior under me. Everything is pinned now, and I upgrade deliberately. Personal infrastructure earns the same version discipline as work infrastructure. You are the on-call.
The API has sharp edges I designed around. An update to a task replaces the full model. So every writer does read-modify-write, or it silently blanks fields. List endpoints paginate at 50 by default. Because of that, my sidebar counts were quietly truncated for weeks. The numbers looked correct and were wrong.
The real gap: this is a single-user system with agent users bolted on. My wife does not use it. Real multi-user family tasks would need permissions, sharing, and notification routing. That is exactly the machinery I left Todoist to escape.
For now, the arrangement stands. I do the deciding. The robots do the filing. Nobody bills either of us monthly for it.