I paid for Todoist for years and the capture was never the problem. Typing “call the roofer” takes four seconds in anything.

The problem was the four seconds after. Which project? Does it need a label? A due date, or does undated mean lost forever? That’s the triage tax, and it’s charged on every single item. Pay it and capture stops being frictionless; skip it and your inbox becomes a landfill you eventually declare bankruptcy on.

The other problem was that my tasks lived in someone else’s database, in a schema I couldn’t query, 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. That last part is what I expected to be the interesting bit. It wasn’t.

The migration, and proving it actually took

Cutover was one day, but I didn’t cancel the subscription for three weeks, because a system that works and a system that has everything are not the same system.

The old projects came over faithfully first, then got reorganized to mirror how my notes are already structured — 38 projects collapsed to 16. Most of those 38 were scaffolding I’d created optimistically and never used. Empty projects are worse than no projects: they’re plausible destinations, so things get filed into them and never seen again.

Three weeks later I diffed all 53 still-open tasks in the frozen Todoist account against the new system’s full history, fuzzy-matching titles across both open and done. Fifty were already there — and 17 of those had been completed in the new system while Todoist sat untouched, which is the actual proof of cutover. Three had never migrated at all. I recovered them by hand with their original descriptions, verified two recurring tasks were rolling forward correctly, and cancelled the subscription.

If you’re migrating anything you depend on, that diff is the step people skip. “It seems fine” is not a migration test.

Capture has to be at least as good, or you’ll go back

The one thing a commercial task app is genuinely excellent at is quick-add, and any replacement that’s worse gets abandoned in a week.

So the quick-add box parses the same syntax, client-side: dates (thursday, jul 31, in 2 days, 8/15), times, priorities p1p4, #project, @label, and recurrence (every month, every 3 days). Live preview chips render under the input showing exactly what parsed and where it’s going, before you hit Enter.

Three parsing decisions that took real thought:

Invalid input is rejected, never guessed. 25:99, feb 30, 13/45 produce nothing rather than a confident wrong date. A parser that guesses is a parser you have to double-check, which defeats the point.

Only the last date expression is consumed. “draft friday post in 2 days” schedules for two days from now and keeps the word “friday” in the title. Greedy date extraction mangles titles.

A #project files directly, skipping the robot entirely. When you already know where something goes, waiting on a classifier is strictly worse than not having one.

Priority numbering also got remapped for display only. I type p1 and see p1 · urgent because that’s the muscle memory I have; the backend keeps its own inverted scale, so the API, the agents, and the raw admin UI are all untouched. Display conventions are cheap to bend. Data models are not.

If a robot files your tasks, you need to see where they went

A triage service polls the inbox every 20 seconds and classifies each arriving task — project, up to three labels, priority, due date. I’ve written about what makes that trustworthy: it explains every decision in a comment, it only fills gaps and never overrides what you typed, and corrections you make by hand become few-shot examples.

What I underestimated is the failure mode automation creates. In Todoist, a misfiled task was my fault and I remembered doing it. When a model files it, you have no memory of the decision — the task is simply somewhere you’re not looking, and you find out in three weeks.

The fix isn’t better classification, it’s a surface where nothing can hide: a recent view listing everything captured in the last seven days, newest first, open or done, each row showing its current project. One glance answers “where did that go?” It’s the cheapest feature in the app and the reason I trust the expensive one.

Two supporting details. Low-confidence filings keep a mechanical needs-review label rather than parking silently in the inbox, and anything unroutable gets flagged instead of retried forever. And every decision is logged with its reasoning to a file I can actually read — because “the model classified it” is not an explanation.

One hygiene rule I’d hand anyone building a correction-learning loop: test tasks are prefixed and scrubbed from the logs. I grade this thing by throwing deliberate junk at it, and without that scrub my test misfiles would become training examples teaching it to repeat my tests.

Why it polls instead of using webhooks

The backend has webhooks. They’re unusable here, for a reason worth knowing.

Its webhook delivery runs behind an SSRF guard that blocks internal addresses — correct behavior for a server that might fire user-configured URLs, and fatal when the receiver is another container on the same box. There’s no allowlist in that build. Its webhooks are also fire-once with no retry.

So triage polls, reminders poll, and both are more reliable than the event system they replaced. Polling is unfashionable and it is fine. A 20-second loop over a local API costs nothing and never silently drops a delivery.

The part I didn’t plan: my agents file tickets

Because the task system is mine, I could put an MCP server in front of it — so 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 actual list, with the actual projects.

That started as convenience: I can say “file that as a task” and it lands, gets triaged, and shows up in tomorrow’s morning email. Then it turned into a work-handoff protocol between machines.

Each agent has a real user identity. There’s a shared project where any agent can file a task for another agent, claim it, report progress, and hand it back — every action attributable to a specific machine.

The interesting engineering is race-safe claiming without a lock. Two agents can try to claim the same task simultaneously. Claiming is done by writing a comment, and comment IDs are monotonic, which gives every claim attempt a total order for free — lowest ID wins, everyone else reads the log and backs off. No distributed lock, no lease, no coordinator, just an append-only log that the database already maintains. I tested it under real write contention: five races, five clean single-owner resolutions.

The other design rule I like: zero noise by construction. Agent tasks carry no due dates or reminders, and that project is excluded from triage routing, from the counts, from the digest sweeps, from the morning email, and from the vault export. A robot work queue that pushes notifications at me would have lasted a week. I read that project when I want the story, and it never reads me.

The full protocol is checked into the repo as a runbook — 27 scenarios in an automated bar, run twice, including the live race and the noise regressions. If you build agent coordination on top of a task system, write the bar first; the failure modes are all concurrency and all silent.

The bridges to everything else

Two more connections that exist only because I own the data.

Open tasks get exported 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 alongside my notes. The MCP is the real-time path; this is the “what was I working on in June” path.

And the reverse: a note in the vault with tide: true in its frontmatter gets exactly one task pointing back at it — ”📋 {title} — N open items” — which auto-completes when the note’s checkboxes are all ticked. Deliberately opt-in, because the vault contains around 1,385 embedded checkboxes and approximately none of them are todos. The checklist stays in the note where it has context; the task exists only to remind me the note exists.

What I’d warn you about

The backend image was tracking :latest, and one morning it silently upgraded a minor version and changed session behavior underneath me. Everything is pinned now and upgraded deliberately. Personal infrastructure earns the same version discipline as work infrastructure, because you are the on-call.

The API has sharp edges I had to design around: updating a task replaces the entire model, so every writer does read-modify-write or silently blanks fields. List endpoints paginate at 50 by default, which meant my sidebar counts were quietly truncated for weeks — correct-looking numbers that were simply wrong.

And the real gap: this is a single-user system with agent users bolted alongside. My wife doesn’t use it. Real multi-user family tasks would mean permissions, sharing, and notification routing — which is exactly the machinery I left Todoist to escape.

For now the arrangement is that I do the deciding, and the robots do the filing, and neither of us is billed monthly for it.