I had a task manager and a notes system. One category of thing fit in neither: packing lists, gift ideas, books to get, and the list of items to buy on the next Florida trip.

Those items are not tasks. A task has a date and an obligation. You are responsible for it, and it nags you when it slips. “Think about the Hario grinder at some point in your life” is not that. Put twenty of those items in a task manager, and you poison it. The “what is due” view fills with undated maybes. The task manager becomes the treadmill of software. It holds clothes now. Then the tool is not trustworthy. Trust is the only property that a task manager has.

The list. app: a 25-item reset list with per-item source links, and the list rail on the left.

The items are not notes either. A note is prose. These are items: you can count them, check them, and reorder them. Each item wants a link and a small note beside it.

So I built a third app. The build took one day, because the stack of the fleet was a solved problem. The interesting parts were the two edges: how an item becomes a real task, and how the system decides which app owns a sentence.

The data model has four fields. One field does all the work.

The text and the note are obvious. The link field gets the page title automatically. Paste a URL, and you see “Baratza Encore ESP Coffee Grinder” and not a bare hostname. This small feature decides if a list is readable six months later.

That harmless feature held a surprise. A server-side link fetcher is a request-forgery machine inside your network. Give it a URL, and it fetches anything. That includes your own internal services. Then it returns their page titles.

So the fetcher does more than fetch. First it resolves the hostname. It rejects private, loopback, link-local, and carrier-grade-NAT address ranges before it connects. It follows redirects manually, to a maximum of four hops. It checks the address again on each hop. A public URL that sends a 302 to an internal address defeats a check that runs only one time. The response has a limit of 512 KB and a timeout of 8 seconds, and the parser reads only og:title or <title>.

That is approximately forty lines of defensive code. The full user-visible benefit of the feature: the link says what it is.

The follow-up: an item becomes a task in a different app

The fourth field is the interesting one.

An item can carry a follow-up. A follow-up is not a flag or a nag inside the lists app. It creates a real task in the task app. The task goes into the inbox. The triage service then assigns a project, labels, and a priority, the same as for all other tasks.

The link points in both directions. The description of the new task holds an HTML anchor back to the exact item: the list, then the item, with a deep link. The item stores the id of the task and shows a link out to it. Neither app owns the relation. Each app holds one end of it.

Two details keep this alive in the real world:

The title has a sane default. Pass a string, and the string is the task title, without changes. Pass true, and you get Follow up: {item text}. The common case needs no thought. The specific case does not fight a template.

A failure does not lose your data. When the task app is down and you add the item, the item still saves. You get a follow_up_error in the reply, the item gets a flag, and a retry endpoint tries only the follow-up again. The alternative refuses to save your note because a different service restarted. That type of coupling makes people stop the use of an app.

There is also a small piece of timezone plumbing that I like. A follow-up due date can arrive as a bare 2026-08-14. That format is ambiguous. The system normalizes it to 9 AM Eastern in RFC3339, and it computes the offset for that specific date: -04:00 in July and -05:00 in January. A hardcoded offset operates for approximately seven months. Then it silently files things one hour off.

The task backend has sharp edges of its own. Know them before you integrate with one. To create a task is a PUT to a project, not a POST. To update a task replaces the model, so each edit is read-modify-write. The priority is a 0–4 scale that needs a clamp. To attach a label is a second call, and it can attach only labels that already exist.

The hard part is routing

Nobody warns you about this part of a fleet: when you own six apps, the hard problem is not an app. The hard problem is the decision about which app a sentence belongs to.

“Add sunscreen to the packing list” is easy. But “add sunscreen”: which list? Is that a grocery item? “Remind me to get sunscreen” is clearly a task. And “what is on my list?” in my house means what is due today. That is a fourth app.

There are four destinations. The words almost do not differ. The number of apps that you own and the ambiguity of the word “list” are directly correlated.

I locked a routing contract on the day that the app shipped. The contract said: the lists app is agent-explicit only. Use it only when I name a list or use clear list words, because a bare “list” collides with everything.

That contract survived exactly one day.

The explicit-only rule optimized for the wrong failure. It prevented bad routing, but it made the app invisible. I had to remember that the app existed, and I had to use special words for it. That friction kills a capture tool. So, on the next day, the app became the default surface for collections of things. To create, add to, or read a list of things goes there, with a name or without one. The response says which list received the item. A wrong guess is visible immediately and cheap to correct.

The rest of the contract is a set of carve-outs, and the carve-outs are the whole thing:

  • Grocery words go to the shopping app, always. Groceries have their own machinery: aisle order, purchase history, and an Echo in the kitchen.
  • “Remind me…”, dated items, and single actionable items go to the task app.
  • A bare “my list” and “what is on my plate” mean tasks that are due, not a list of things.
  • One-off thoughts get filed nowhere. A collection is multiple keep-track items, or explicit list words.

My advice for a fleet builder: write the routing rules in prose, one time, and make each consumer read that same document. My assistant loads it, the API docs of the app load it, and my top-level config loads it. The alternative is routing logic that lives as vibes in three different prompts. The prompts drift apart quietly. Then, on vacation, you find that half of your packing list went somewhere else.

Also: make the destination visible in each response. “Added to Packing — Muskoka” permits a one-word correction when it is wrong. Silent success is how items disappear.

Apps talk to apps

The most recent addition points the lists app at the shopping app. A row action pushes an item across. A list with the “shopping run” flag can send each open item across in one two-tap sheet.

Two mechanics came from painful experience in other places:

Each send carries an idempotency key that comes from the id of the item. The receiving app maps retries and repeated sends onto the same row, also when you already bought the item. A retry cannot resurrect a checked-off item. That specific bad behavior erodes trust in sync.

Items go across with a literal mark. The shopping app has a trick: some names explode into ingredient lists. Say “tacos”, and you get the full shop. That is perfect at the fridge. It is a catastrophe when a packing list contains the word. The literal flag says: this is a string, not a command. That one boolean is the difference between a feature and a landmine.

The outbound call runs on the server side. It has an eight-second timeout and a guard for a connection that dies in mid-response. A restart of the receiving app can never hang this one.

What is still wrong with it

Before the bridge shipped, a reviewer with zero knowledge of the project attacked the spec. It came back with ten blockers. That was humbling and correct.

The worst blocker is latency math that I did not do. To compile a big list is one outbound call per item. Thirty items against an eight-second timeout is a four-minute operation with no progress model. Batches have a limit of fifty, and a changed list invalidates the snapshot with a “list changed — reopen” message. But “it is slow and you cannot see it work” is not solved. It is only bounded.

There is a UI misdirection that I know about and did not fix: the app can send an item to one household while its chip opens the other. And deduplication on the receiving side matches on the name, not on a canonical identity. Aliases can pass through as duplicates.

None of that is hard. It is only not done. I prefer to say so, not to pretend that the seams are not there. The app pulled its weight from day one. My packing list is no longer four screenshots and a promise.