The problem
Every service I run wants to tell me something. Loop wants a decision. A backup failed. A disk is nearly full. An agent session is blocked on a permission prompt. Left alone, each service grows its own way of reaching me, and none of them agree on what “important” means or when I’m asleep.
So I built one place for all of them to go: one envelope every service speaks, one set of rules for what’s important and when I’m asleep, and channels anyone can add.
What I built
Headlines is a small service with no UI of its own. Producers push events in. Headlines decides whether, where and when I see each one — mute, per-kind policy, Do Not Disturb, importance, live updates — and delivers through channels. By default that’s the operating system’s own notification surface. Anyone can mount more: ntfy for my phone, Discord, Slack, a webhook.
It never imports, calls or names a producer, and a producer never needs to know which channels exist. The envelope is the whole contract.
Built for agent sessions
Most notification tools were built to tell a person that something happened. Agent sessions need something else: a way to ask a person something and wait for the answer. That’s the case Headlines is built around.
- It interrupts you only when a session needs you. An event marked
blockingmeans a person has to act: a permission prompt, a decision, a session that’s stuck. Blocking events are never held by Do Not Disturb. Everything else follows your policy, so forty sessions don’t add up to forty pings. - You answer on the card. A blocking event carries its own buttons: allow, allow for this session, deny. The session that asked waits on
headlines askuntil a person answers, then carries on with the decision. You don’t need to find its terminal. - You can steer, not just approve. Answer deny with a note, and the note goes back to the session as feedback. Today the note goes in from the CLI or the API; the card’s buttons cover the one-click answers. “No, use the existing helper” reaches the agent without you opening its window or losing your own place.
- Every card knows its session. Each event carries the session that raised it, whether that’s a Herdr pane, a terminal or a Loop run. So a card says which of your sessions needs you, and Surface can line the cards up by session tab.

How it works
- Three doors, all equivalent: a CLI for scripts and hooks on the same machine,
POST /v1/eventsfor servers and containers, and an inbox folder for anything that can only write files. All three call the same ingest function, so policy and the ledger can’t be bypassed. - The envelope needs only a
kindand a title or body. Optional fields carry severity, a category for coarse muting,blocking(a person must act, never held by Do Not Disturb), the session that produced it, buttons, links, and a cost. - A live event updates in place: same live key, same id, rising revision, and channels aren’t rung again. An event can also resolve an earlier one, so “disk nearly full” clears itself when the disk recovers.
- A channel is one folder with one file and one
deliver()function. Channels must never throw, must time-box every network call to five seconds, and run in parallel so one slow channel can’t delay a producer. - Every ingest, hold, delivery and failure is a line in an append-only ledger. The CLI’s output tells you exactly what happened: delivered to which channels, muted and why, or “spool” — recorded, but no channel is enabled, which is a misconfiguration and not a choice.
Decisions worth noting
Secrets never sit in the config file. Any value can be env:NAME, read at delivery time. Headlines refuses to listen beyond localhost without a token.
A new producer never changes Headlines’ code. If I catch myself editing src/ to support one service, the envelope is missing a general field, and that’s what gets proposed instead. No producer’s name appears in the source.
Spend metering lives here too, and it counts carefully. The service reads Claude Code session transcripts every 30 seconds and emits one live cost event per session. One message shows up on several transcript lines, and summing lines overcounts by about 2.5×, so it deduplicates by message id first. Each session gets two prices: simulated (what it would cost at API list prices) and actual (what was billed — zero for a subscription). Cost events are recorded but never sent to a channel.
Zero dependencies. Node built-ins only, which matters for something that has to start at login and never be the reason a notification went missing.
Where it fits
Notifications flow into Headlines; Headlines never reaches into a producer. Collab and Loop publish to it. Mac the Reaper sends its approval requests through it. Surface is a viewer on top of it: its activity log, its pinned headline band, its session tabs and its burn bar all read from Headlines’ events and spend rollup.
Outcome
Headlines v0.1 runs on my Mac as a launchd service. Collab, Loop and Mac the Reaper publish to it, and Surface draws from it. Switching a producer over from the work bus’s CLI is one environment variable, because the flags and output line match.
What isn’t built yet, and I’d rather say so: an MCP door for agents with no shell, push routes for harnesses that expose their own notification API, and reconciling OpenRouter estimates against real billed usage.