Tononoff Journey

skill · 10

notify: the agent shouts down the hallway

Your machine can already talk; the hard part is stopping the agent from reading file paths aloud.

The problem with an agent that works for eleven minutes is that you don't watch it for eleven minutes. You leave, and then you either come back too early and hover, or too late and it's been waiting six minutes for a yes/no.

Every product answer involves a push service, an API key and a phone. notify is the unglamorous one: say and afplay, both already on the machine. No key, no network, no telemetry, no vendor waiting for a credit card. On macOS the audio is two subprocess.run calls; everything interesting sits upstream.

Installing it is two lines in Claude Code, and the only prerequisite is uv on your PATH:

/plugin marketplace add lucastononro/notify
/plugin install notify@notify-marketplace

Codex is the same shape — codex plugin marketplace add lucastononro/notify, then codex plugin add notify@notify-marketplace. Check it works by asking for the smallest possible thing: play a sound, or ping me when the tests pass.

§Reads fine, sounds awful

A message that reads fine is usually unlistenable, and nothing tells the model a sentence must survive being spoken. So the rules live in the notify docstring, the one place the model definitely reads:

Speech-friendly rules — the model MUST follow these when composing beats:
- Plain English sentences only. No tables, bullets, markdown, code, file
  paths, URLs, long numbers, version strings, or punctuation-heavy text.
- Each beat should be short and natural — read it out loud mentally first.
  Typical beat: one sentence, ~5–20 words. Length follows content.
- Split into multiple beats when content has distinct parts …

Every prohibition maps to a failure mode. Tables get read column by column and arrive as nonsense. Paths get spelled: src/foo.ts becomes "S R C slash foo dot T S". Percentages get read literally, so 87.4% (+2.1%) is a run of digits you can't hold in your head. Hashes and version strings are worse.

The skill pairs each with what you want to hear. Not "Tests failing in src/auth/login.test.ts at line 47" but "Auth tests are failing on the login endpoint." Not "Coverage: 87.4% (+2.1%)" but "Coverage is up two percent." Not "Blocked. Options: 1) Postgres, 2) Mongo, 3) SQLite" but "Blocked. Need you to choose between Postgres and Mongo."1The rules stop short of banning acronyms: API reads acceptably as "A P I", but SQL comes out as "Sequel" — which the skill flags with "Test if unsure."

That bad column is constructed, not collected — but it's what a model writes by default, because by default it writes for a screen. Note what the rewrites give up: the line number, the exact figure, the third option. That's not compression, it's a change of medium. Detail belongs in the chat.

§Beats, not paragraphs

The other half is pacing. One long sentence, spoken, arrives as a wall — you catch the opening clause and miss the verdict. So the tool takes beats, not a string:

for beat in beats:
    text = (beat.get("text") or "").strip()
    if not text:
        continue
    _speak(voice, text)
    spoken += 1
    pause = beat.get("pause_after", 0.3)
    if pause and pause > 0:
        time.sleep(min(float(pause), 5.0))

Each beat is its own say invocation, so the gap is real silence, not a comma the synthesizer may or may not honor. pause_after defaults to 0.3 seconds and is clamped by min(float(pause), 5.0) — a model that can pass a float will eventually pass thirty, and a tool that holds the session for half a minute is worse than silence. Empty beats are skipped, same instinct.

The docstring caps the count at three: "1–3 beats is normal; more than 3 means rewrite shorter." Past three you aren't notifying, you're presenting. The return is a plain sentence — Played 2 beat(s) with voice 'Ava (Premium)' on mac — so the model sees what it did.

§Fourteen sounds, seven categories

Sound names are public API and platform-agnostic. The indirection that buys that: one dict from name to semantic category, a second from category to whatever the local system calls it:

SOUND_CATEGORY = {
    "hero": "success", "glass": "neutral", "sosumi": "blocked", "basso": "error",
    "funk": "warning", "ping": "fyi", "tink": "fyi", "submarine": "attention",
    …
}
LINUX_CANBERRA_EVENT = {
    "success": "complete", "neutral": "bell", "fyi": "message",
    …
    "blocked": "dialog-warning", "error": "dialog-error",
}
sosumi 1 of 14 names blocked 1 of 7 categories macOS afplay /System/Library/Sounds/Sosumi.aiff Linux canberra event: dialog-warning else dialog-warning.oga via paplay Windows %WINDIR%\Media\Windows Exclamation.wav else MessageBeep MB_ICONEXCLAMATION hop 1 MAC_SOUNDS is keyed by name hop 2 nothing resolves a descriptive string, not an exception Could not play sound 'sosumi' …
One name end to end: two hops off macOS, one on it, and a sentence rather than an exception when every candidate misses.

Fourteen names, seven categories. On macOS the name resolves straight to /System/Library/Sounds/Hero.aiff. Everywhere else the category does the work, so an unfamiliar box degrades instead of failing. Windows walks a list of %WINDIR%\Media candidates, then drops to a winsound.MessageBeep flag for when Microsoft has renamed the WAVs again. Linux asks the freedesktop theme for the event, then hunts .oga files under /usr/share/sounds/freedesktop/stereo.

sosumi is the one I care about — blocked, the agent wants a decision. It sounds genuinely unlike basso, which is error. From the next room that difference is the whole product: one means come back, the other means it can wait.

§Voices and guards

Voice selection is a preference ladder: VOICE_PRIORITY runs from "Ava (Premium)" down through Premium and Enhanced voices to plain "Samantha", which ships on every mac. The server runs say -v ? once, takes the first entry installed, and caches it in _voice_cache.2The check is a regex anchored to a locale code — the name followed by \s+[a-z]{2}_[A-Z]{2} — because say -v ? prints a sample sentence beside each voice, and a substring match hits the sample.

Nothing on that path raises. A missing voice, an absent sound file, a Linux box with no paplay — _play returns False, _speak returns quietly, and the tool turns that into a sentence: Could not play sound 'hero' on linux (no audio backend available). A misspelling gets Unknown sound 'heroo'. Valid: basso, blow, bottle, ….

That's deliberate: the caller is a language model, and a descriptive string is something it can act on where an exception is a wall. A crashed attention tool leaves the agent stuck and silent.

§One command, two harnesses

The MCP server is a published package, not a bundled script. Both plugins register it with a byte-identical command:

uvx --from "git+https://github.com/lucastononro/notify#subdirectory=mcp-server" notify-mcp

uvx builds and caches it from git on first run with its single dependency, mcp[cli]>=1.2.0, resolved against the notify-mcp console script in pyproject.toml. No path appears in either config — no plugin-root substitution variable, no venv, nothing to break on a clone elsewhere. The only difference is the JSON around it: Claude Code wants {"mcpServers": {…}}, Codex a bare server map.

The skills diverge more than I expected. Claude Code's SKILL.md calls the tools mcp__notify__notify and declares allowed-tools: … Bash(afplay:*), Bash(say:*), so it carries a macOS-only shell fallback re-implementing the voice ladder in sed and grep. Codex's calls them notify and play_sound, has no allowed-tools, drops the fallback, and keeps its trigger policy in a sibling agents/openai.yaml — allow_implicit_invocation: true plus a declared MCP dependency.3The descriptions diverge too. Codex's packs the whole trigger-and-don't-trigger policy into description; Claude Code's ends in a keyword list — "Triggers on: notify me, ping me, …".

macOS is tested. Windows and Linux are marked best-effort and untested, and the whole platform layer is one 327-line file — so when it's wrong, it's wrong somewhere obvious.

§Three ways I actually use it

Here it is talking, so the rest of this is less abstract. Turn your sound on!! The whole artifact is audio, and a silent screenshot of it would be a blank rectangle:

A throwaway demo: I asked it to explain something at random, purely so there is a recording of the thing speaking.

That's a deliberately pointless message. The three below are what I actually ask for, verbatim.

Waiting on an interval.

loop on this every 15 minutes: check whether the sweep has finished.
stay silent while it's still running. when it's done, notify me with the
best validation score and whether it beat the baseline.

Polling is the thing computers are for, and my attention is the expensive resource. The instruction that earns its place is stay silent while it's still running. Without it you get a chime every fifteen minutes telling you the thing you already knew, and by the third one you've stopped hearing chimes (ask me how I know).

Telling parallel sessions apart. This is the one that changed how I work.

when you're finished, notify me — and name the repo you were working in and
what you actually changed, so I know which window to come back to. don't
just say "done".

With four cloud sessions running, "done" is a useless notification: it tells me something finished, not which tab to open. What I want is "The billing migration is in. Two tests failed on the retry path" — now I know where to go before I've looked at anything. This is where the speech-friendly rules stop being fussy and become the product, because the beat has to name the project in words. A session id read aloud is noise, and four sessions all saying "task complete" is worse than silence.

Being interrupted on purpose. For a long session pointed at one goal, I ask for two different pings with two different sounds:

work through this on your own — I'll be away. play `hero` and notify me
when it's green. if you hit a decision that's mine to make, stop and
notify me with `sosumi`, and say what the decision is in one sentence.

The second half is by far the more valuable. A finished agent costs me a few minutes of latency. A blocked agent that waits silently costs the rest of the session, and there's no upper bound on how long it will sit there being polite.4Naming the sound in the prompt is optional — the skill picks a sensible one from the situation. I name it anyway for the second case, because blocked and broken need to be distinguishable from the next room, and I would rather not trust a judgment call about my own kitchen acoustics.

None of that needed a feature. It's the same two tools with a different sentence in the request, which is about the ratio I'd expect: the tool is trivial, and the discipline is knowing what to ask to be told.

§What the medium demands

Nothing in a model's training tells it the output will be spoken. Its default target is a screen: a table, a path, a figure to three decimals. So everything in notify that isn't two subprocess.run calls is a translation layer for that mismatch, written where the model definitely reads it, not in prose it might skim. That's the transferable part. Move an agent's output somewhere new — a watch line, a Slack push, an email subject, a sound — and you inherit constraints nobody has written down. The tool has to state them, because the model can't notice them from the inside.

The honest scope is narrow. This solves one person's annoyance about hovering over a terminal. The piece I'd actually miss is the smallest one: sosumi against basso, blocked against error, which only means anything once you've already left the room. Take away the beats and the sound table and I'm back to checking every two minutes.


Side effect I didn't expect: I now leave the room on purpose.

Sign in with GitHub and say something. Threads live in this repository's Discussions, so you keep your words and I keep no database.