TLDR: Two organizing axes = everything shows up twice. Pick ONE. Then delete.

The complaint that exposed everything

"The Email page is showing emails AND Slack — super confusing."

That was me, looking at Apollo Dashboard (my local AI-powered cockpit, a Dock-launched localhost app) v14.

I was right. But when I actually dug in, it was worse than I expected.

The two-axis trap I'd built

The cockpit had two pages organized by priority:

  • /email — email action items AND Slack action items, merged
  • /attention — email FYI AND Slack FYI, merged

And one page organized by source:

  • /slack — all Slack, sorted by bucket

So every single Slack message was rendering twice.

A Slack action item showed up on /email AND /slack. A Slack FYI hit /attention AND /slack. The page literally labeled "Email" contained Slack.

I had built a dashboard with two organizing brains running simultaneously — one that thought in priority, one that thought in source — and duct-taped them together without noticing.

The fix I almost made (and didn't)

The tempting move was a dedup pass. Just filter the overlapping cards server-side. Clean, surgical, no deletions required.

I thought about it for maybe 30 seconds.

The problem isn't the duplication — it's the model. Deduplication would have hidden the symptom. The next time I added a new data source, I'd have rebuilt the exact same mess.

Source-first, for real

v15 picked source as the single organizing axis. One rule, applied everywhere:

  • /email = email only. Needs Action + FYI. No Slack anywhere on this page.
  • /slack = Slack only. Same two sections to match. A DM / @you / #channel chip on each card so you know at a glance what kind of Slack it is.
  • /attention = deleted. The whole page. Gone. It existed entirely to merge sources that now each have their own home.

Deleting /attention felt more productive than writing a single line of new code.

The drafting bill I'd been quietly paying

Source-first also forced a cost audit I'd been avoiding.

FYI messages on /email had been getting pre-drafted by Sonnet — a metered Agent SDK call per message, DRAFT_BUCKETS = ("action", "fyi").

FYI doesn't need a reply. I'd been paying to pre-write words that never got sent.

Cut DRAFT_BUCKETS to ("action",).

That reversed a deliberate v13 decision — at the time, I'd explicitly chosen to pre-draft every message. Different context, different cost picture. Superseded.

The isolated port trick that caught a ghost

The dashboard runs on a hardcoded :7842. No auto-reload. Long-lived Python process.

So I spun the new code on port :7899 before touching the live server.

First curl on :7842: /attention returned 200. Correct — the OLD binary was running.

curl on :7899: /attention returned 404. Correct — the page was gone.

Without that isolated test, I'd have assumed the deletion worked and spent an hour wondering why the /attention page kept loading fine.

Why this one sticks with me

The lesson isn't "use source-first." Your organizing axis depends on your problem.

The lesson is: name your axis and use it everywhere. The moment two axes run simultaneously, duplication is inevitable, confusion is baked in, and no amount of dedup logic fixes the underlying design.

And when the cleanup reveals you're paying for things you don't need — cut them without guilt.