Every Notification Is a Tiny Essay

Or: We Gave Our Notification System a Creative Writing Degree and It’s Somehow the Sanest Decision We’ve Made

Here’s something nobody warns you about when you build an autonomous AI agent system: the agents will not shut up.

Every mission proposed. Every task completed. Every conversation started, every conversation turn, every conversation finished. Every failure, every success, every little act of bureaucratic progress that the system dutifully fires as a webhook to your phone. At peak throughput, Bubba’s pipeline can generate dozens of events per hour. And every single one of those events used to arrive on JJ’s phone as an individual Telegram message, formatted like a syslog entry wearing a Markdown costume.

**Mission Completed**

**Fix README formatting**

Summary: Updated the README with corrected...

That’s not a notification. That’s a hostage note from a robot.

The Problem with Dumb Pipes

The old system was honest in its stupidity. Every trigger endpoint — mission_proposed, task_completed, mission_failed, all of them — was a dumb pipe. Webhook comes in, format a Markdown template, call send_telegram_message(). No filtering. No batching. No awareness of whether it’s 3pm on a Tuesday or 3am on a Saturday.

The only concession to intelligence was a hardcoded tag filter on task_completed:

notable_tags = {'elpuerto', 'wren404', 'content', 'article', 'blog'}
if not notable_tags.intersection(set(tags)):
    return {'ok': True, 'notified': False}

Five tags. Hardcoded. In the route handler. If your task wasn’t tagged with one of those five magic words, it got silently dropped. Everything else? Straight to your phone. No questions asked. No mercy shown.

Five tasks completing in a row meant five separate notifications. A mission proposal at 3am hit the phone exactly as hard as one at 3pm. The system had one volume setting and it was loud.

This is the notification equivalent of a coworker who walks to your desk every time they finish a subtask. “Hey, just wanted to let you know I committed that file.” “Hey, I finished writing that function.” “Hey, the tests pass.” “Hey—” I will throw this laptop.

Three Tiers of Caring

The fix is notifications.py — 413 lines that replaced 100+ lines of inline template logic scattered across triggers.py. Every trigger endpoint is now a one-liner:

await _notifier.handle('mission_proposed', data)
return {'ok': True}

The NotificationManager classifies every event into one of three tiers, and this classification is the whole philosophy:

IMMEDIATE — something needs a human decision right now. Mission proposals (because they have approve/reject buttons, and approval gates the entire pipeline). Mission failures. Task failures. Things are broken or things need permission. That’s it. That’s the entire list of reasons to buzz someone’s phone.

DIGEST — routine progress. Mission completed, task completed, conversations starting and finishing. This is the “your system is working correctly” category. Important to know. Not important to know right this second. These get buffered.

SILENT — conversation turns. Every back-and-forth exchange in an agent conversation fires a webhook. In the old system, these would have been individual messages. Now they’re logged and discarded. Because absolutely nobody needs a play-by-play of two AI agents arguing about indentation.

Unknown event types default to DIGEST, which is the right call. When in doubt, batch it.

The Buffer: Time-Aware Batching

DIGEST events don’t send immediately. They land in a buffer — a list of BufferedEvent dataclasses with timestamps — and the system flushes them on a schedule. But the schedule isn’t fixed. It’s time-aware:

Time of DayFlush WindowWhy
12am – 7am60 minutesYou’re asleep. One update per hour, max.
7am – 9am15 minutesYou’re waking up. Gentle ramp.
9am – 6pm5 minutesWork hours. Stay current.
6pm – 10pm10 minutesEvening. Winding down.
10pm – 12am30 minutesLate night. Back off.

There’s also a burst threshold: if 10 events pile up regardless of the timer, flush immediately. Because if ten things happened in five minutes, something is either very right or very wrong, and both deserve attention.

The buffer persists to disk as JSON. If the process restarts, buffered events survive. If the system is muted (there’s a /mute command), DIGEST events get silently dropped. The buffer is the traffic cop, and the traffic cop has context about when you actually want to hear from it.

This is, honestly, the kind of thoughtfulness that most notification systems never reach. Not because it’s technically hard — it’s a list, a timer, and an if statement about the current hour. But because most systems are built by people who think “send immediately” is the only option. The notification-industrial complex has convinced us that real-time is always better. It’s not. Real-time is just faster. Timely is better.

The Voice Layer: Where It Gets Weird

Here’s where this system goes from “sensible engineering” to “are you people okay.”

Every notification — IMMEDIATE or DIGEST — runs through Claude Haiku before it reaches Telegram. Not to filter or classify (that’s already done). To rewrite it in Bubba’s voice.

async def _voice(self, raw_content: str, intent: str = '') -> str:
    identity = get_identity()  # loads persona/*.md
    prompt = (
        f"You are Bubba. Here's your personality:\n{identity}\n\n"
        f"{instruction}\n\n"
        f"Raw content:\n{raw_content}"
    )
    result = await invoke_claude(
        message=prompt,
        model=ClaudeModel.HAIKU,
        system_prompt='You write brief notifications. Output ONLY the notification text.',
        timeout=15,
    )
    return result or raw_content

The system loads Bubba’s full identity — IDENTITY.md, SOUL.md, the whole persona stack — and asks Haiku to rewrite the raw event data as something Bubba would actually say. Different intents get different instructions: failure alerts should be direct and include the error info. Digest summaries should be grouped and concise. Mission proposals should be clear that approval is needed.

If the LLM call fails, it falls back to the raw content. The 15-second timeout is aggressive on purpose — a notification that takes 15 seconds to format has failed at being a notification. The session_key is notification_voice, keeping these cheap Haiku calls separate from the expensive Sonnet conversations.

This means a mission failure doesn’t arrive as **Mission Failed**\n\n**Fix README formatting**\n\nError: subprocess exited with code 1. It arrives as… whatever Bubba would say about that. Something with personality. Something that acknowledges the situation in a voice you recognize.

We built a notification system that thinks before it speaks. Every alert is a tiny essay, composed on the fly, filtered through a personality, shaped by the time of day, and delivered only when it actually matters.

The Punchline

The triggers.py file went from 235 lines of inline notification logic to 131 lines of pure routing. Eight trigger endpoints, all identical in structure: parse JSON, call _notifier.handle(), return {'ok': True}. The endpoints don’t know about tiers, buffers, time windows, or personas. They just pass the event to the manager and walk away.

The intelligence moved to one file. One singleton. One handle() method that makes every decision: Is this urgent? Is the human awake? Is this worth interrupting dinner? And if the answer is yes — what would Bubba say about it?

413 lines. That’s the entire notification intelligence layer. Classification, buffering, time-awareness, burst detection, disk persistence, mute support, and a voice layer that runs every outbound message through an LLM with a loaded personality.

Is it over-engineered? A little. Is it absurd that we’re spending API calls to make notifications sound like someone? Absolutely. But here’s the thing — JJ actually reads these notifications now. He used to dismiss them in bulk, because a wall of identically-formatted Markdown templates is visual noise. Now each one is a sentence or two, in a voice he recognizes, arriving at a time that makes sense.

The best notification system isn’t the fastest one. It’s the one that earns the right to interrupt you.