I had a task manager and I had a notes system, and there was still a category of thing that fit in neither: packing lists, gift ideas, books to grab, the running list of stuff to buy next time we’re in Florida.
Those aren’t tasks. A task has a date and an obligation attached — you’re on the hook, and if it slips it nags you. “Consider the Hario grinder at some point in your life” is not that. Put twenty of those in a task manager and you’ve poisoned it: your “what’s due” view fills with undated maybes, and the whole thing stops being trustworthy, which is the only property a task manager has.
They aren’t notes either. A note is prose. These are items — countable, checkable, reorderable, each wanting a link and a scribble beside it.
So: a third app. It took a day to build, because the fleet’s stack was already a solved problem, and the interesting parts turned out to be the two edges — how an item gets promoted into a real task, and how anything decides which app it belongs in.
An item is text, a note, a link, and a maybe-obligation
The data model is four fields and one of them is doing all the work.
Text and a note are obvious. The link auto-fetches its page title, so pasting a URL gives you “Baratza Encore ESP Coffee Grinder” instead of a naked hostname — a small thing that changes whether a list is readable six months later.
That innocuous feature had a surprise in it. A server-side link fetcher is a request-forgery machine living inside your network. Hand it a URL and it will faithfully fetch anything, including your own internal services, and hand back their page titles.
So it doesn’t just fetch. It resolves the hostname first and rejects private, loopback, link-local, and carrier-grade-NAT address ranges before connecting. Redirects are handled manually, up to four hops, and it re-checks the address on every single hop — because a public URL that 302s to an internal address defeats a check you only run once. The response is capped at 512 KB, has an 8-second timeout, and only ever parses og:title or <title>.
That’s maybe forty lines of defensive code guarding a feature whose entire user-visible benefit is “the link says what it is.”
The follow-up: promoting an item into a different app
The fourth field is the interesting one.
Any item can carry a follow-up, and a follow-up isn’t a flag or a nag inside the lists app. It creates a real task in the task app — filed into the inbox, where the triage service picks it up and assigns a project, labels, and a priority like anything else.
The link goes both directions. The new task’s description carries an HTML anchor back to the exact item — list, then item, deep-linked. The item stores the task’s id and renders a link out to it. Neither app owns the relationship; they both hold one end of it.
Two details that make it survive contact with reality:
Title semantics have a sane default. Pass a string and it’s the task title, verbatim. Pass true and you get Follow up: {item text}. The common case needs no thought and the specific case isn’t fighting a template.
Failure doesn’t lose your data. If the task app is down when you add the item, the item still saves. You get a follow_up_error back, the item is flagged, and a retry endpoint re-attempts just the follow-up. The alternative — refusing to save your note because a different service was restarting — is the kind of coupling that makes people stop using an app.
There’s also a small piece of timezone plumbing I’m fond of. A follow-up due date can come in as a bare 2026-08-14, which is ambiguous. It gets normalized to 9 AM Eastern in RFC3339 — with the offset computed for that specific date, so it’s -04:00 in July and -05:00 in January. Hardcoding the offset works for about seven months and then silently files things an hour off.
The task backend has its own sharp edges worth knowing if you ever integrate with one: creating a task is PUT to a project, not POST; updating a task replaces the model, so every edit is read-modify-write; priority is a 0–4 scale that needs clamping; and attaching a label is a second call that can only attach labels that already exist.
The hard part is routing
Here’s what nobody warns you about when you build a fleet: once you own six apps, the difficult problem stops being any app and becomes deciding which one a sentence belongs to.
“Add sunscreen to the packing list” is easy. But “add sunscreen” — which list? Is that groceries? “Remind me to grab sunscreen” is clearly a task. And “what’s on my list?” in my house means what’s due today, which is a fourth app entirely.
Four destinations; the words barely differ.
I locked a routing contract the day the app shipped. It said the lists app was agent-explicit only: reach for it only when I name a list or use obvious list phrasing, because a bare “list” collides with everything.
That contract survived exactly one day.
The problem with explicit-only is that it optimized for the wrong failure. It prevented mis-routing at the cost of making the app invisible — I had to remember the app existed and phrase things for it, which is precisely the friction that kills a capture tool. So the next day it became the default surface for collections of things: creating, adding to, or reading any list-of-things goes there, named or not, and the response says which list it landed on so a wrong guess is instantly visible and cheap to correct.
The rest of the contract is carve-outs, and they’re the whole thing:
- Grocery wording goes to the shopping app, always. Groceries have their own machinery — aisle order, purchase history, an Echo in the kitchen.
- “Remind me…”, anything dated, and single actionable items go to the task app.
- Bare “my list” and “what’s on my plate” mean tasks due, not a list of things.
- One-off musings get filed nowhere. A collection is multiple keep-track-of-these things, or explicit list wording.
What I’d tell anyone building their own fleet: write the routing rules down in prose, once, and make every consumer read that same document — mine is loaded by the assistant, the app’s own API docs, and my top-level config. The alternative is routing logic that exists as vibes in three different prompts, quietly drifting apart, and you find out on vacation that half your packing list went somewhere else.
Also: make the destination visible in every response. “Added to Packing — Muskoka” is a one-word correction when it’s wrong. Silent success is how items disappear.
Apps talking to apps
The most recent addition points the lists app at the shopping app: any item can be pushed across with a row action, and lists flagged as a “shopping run” can compile every open item over in one two-tap sheet.
Two mechanics carried over from painful experience elsewhere:
Every send carries an idempotency key derived from the item’s id, so the receiving app converges retries and re-sends onto the same row — even if you already bought it. A retry can’t resurrect a checked-off item, which is the specific bad behavior that erodes trust in sync.
Items go across marked literal. The shopping app has a trick where certain names explode into ingredient lists — say “tacos” and you get the whole shop. Perfect at the fridge; catastrophic when a packing list contains the word. The literal flag says this is a string, not a command, and that one boolean is the difference between a feature and a landmine.
The outbound call happens server-side, with an eight-second timeout and a guard for a connection that dies mid-response, so the receiving app restarting can never hang this one.
What’s still wrong with it
Before the bridge shipped, I had a reviewer that knew nothing about the project tear into the spec. It came back with ten blockers, which was humbling and correct.
The worst is latency math I hadn’t done: compiling a big list is a per-item outbound call, so thirty items against an eight-second timeout is a four-minute operation with no progress model. Batches are capped at fifty and a changed list invalidates the snapshot with a “list changed — reopen,” but “it’s slow and you can’t see it working” is not solved, it’s just bounded.
There’s a UI misdirection I know about and haven’t fixed: an item can be sent to one household while its chip opens the other. And deduplication on the receiving side matches on name rather than a canonical identity, so aliases can slip through as duplicates.
None of that is hard. It’s just not done, and I’d rather say so than pretend the seams aren’t there. The app has pulled its weight since day one anyway. My packing list is no longer four screenshots and a promise.