I work with Claude Code most of the day, often with more than one session open: one refactoring in one project, another writing tests in a different one. The problem is that an agent does not tell you anything. It finishes, or it stops to ask permission to run a command, and it just sits there until you notice.
I noticed late. I would come back to the terminal after ten minutes and find the agent had spent nine of them waiting for me to say "yes". Or the opposite: I kept watching the screen just in case, which is exactly what I was trying to stop doing by handing the work off.
The obvious fix is a desktop notification, and it did not work for me. A notification competes with every other notification, shows up on the monitor you are not looking at, and disappears on its own. What I wanted was what a person sitting next to you does: tell me, out loud, "I'm done with this" or "I need you to look at something".
That is how HeyDev started. It began as a couple of loose hooks in my config and ended up as a plugin you install with two commands.
What it sounds like
My setup speaks Spanish, so when Claude finishes a task I hear something like this:
"Gastón, terminé en tienda online. Listo el endpoint de pagos, falta probar el webhook."
"Gastón, I'm done in online store. The payments endpoint is ready, the webhook still needs testing." And when it stops to ask for a permission or asks me a question:
"Gastón, en app del clima necesito tu atención."
"Gastón, in weather app I need your attention." The project name comes from the folder (tienda-online is read as "tienda online"), so with several sessions open I know which one is talking without looking. The second sentence is the interesting one: it is not fixed text, it is a summary of what it did.
Claude writes the summary itself
The first idea for getting a summary was the obvious one: take Claude's reply and send it to another model to boil it down to one line. It works, but it is one more call per notification, with its own cost, its own latency and its own API key.
But Claude already knows exactly what it did. It is the best placed to summarise it, and it is already generating text. So instead of summarising afterwards, I ask it to do it while it answers.
Claude Code has hooks: commands that run at specific points in a session. The plugin uses three:
| Event | What it does |
|---|---|
SessionStart |
Adds an instruction to the context: end every reply with <!-- voice: summary -->. |
Stop |
When Claude finishes, finds that comment and says it out loud. |
Notification |
When Claude asks for a permission or a question, says it needs attention. |
The trick is the format: an HTML comment. Claude Code renders replies as markdown, and in markdown an HTML comment is invisible. The reply looks exactly as it always did; the summary travels hidden on the last line and only the hook reads it:
MARCA = re.compile(r"<!--\s*voice:\s*(.+?)\s*-->", re.S)
def resumen_de(texto):
hallados = MARCA.findall(texto or "")
return hallados[-1].strip() if hallados else ""The instruction asks for twelve words at most, in the notification language, in the first person, with no paths and no code, because a file path read aloud helps nobody. If a reply has no summary (a quick question, say), the notification just says "done in such-and-such project" and that is it.
Zero extra calls. The summary is free because it was already in the reply.
Latency: the hook cannot wait
A Claude Code hook runs in the middle of your session. If it is slow, the session is slow. And speech is not instant: synthesising a sentence takes a moment, and playing it takes as long as the sentence lasts.
So the hook does none of that. It reads what Claude Code sends it, pulls out the summary, starts a separate process and exits. Everything slow runs in the background:
def en_segundo_plano(evento, datos):
cmd = [sys.executable, __file__, "_trabajo", evento, json.dumps(datos)]
subprocess.Popen(cmd, start_new_session=True,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)Measured end to end, the hook returns in about 50 milliseconds. You do not notice it.
Then there are two more decisions so the notification arrives fast and sounds smooth:
- Fixed phrases are cached. "Gastón, I'm done in online store" sounds the same every time, so the audio is generated once and saved under a hash of the text. The second time nothing is synthesised: it just plays.
- The summary is generated while the first part plays. The parts of a notification are synthesised in parallel, and by the time the first finishes playing the second is ready. No gap in the middle.
And one detail that shows up as soon as you run more than one session: two agents finishing at the same moment talk over each other. A lock on a file fixes it. Whoever arrives second waits its turn and speaks afterwards, like a queue.
A local voice, no APIs
There were two ways to get a voice. One was a cloud text-to-speech service: they sound great, but every notification is a network call, with its own latency, cost and API key. And what you send are summaries of what you are doing in your code, which have no reason to leave your machine.
The other was a local model. I picked Piper, a neural speech engine that runs on ONNX. The default voice is es_AR-daniela-high, a River Plate Spanish voice of about 114 MB. What mattered most to me:
- It runs on the CPU. No GPU needed, and a short sentence is generated in much less time than it takes to say it.
- No accounts, no keys. After installation it never connects to anything again.
- It does not depend on someone else's service being up, or on prices or free tiers changing.
The install is about 300 MB between the Python environment, Piper and the voice, and it happens once, in the background, the first time you open a session. Meanwhile the plugin uses the system's built-in voice, so notifications work from minute one. When it finishes it tells you it has its voice.
The script itself only uses the Python standard library. Everything heavy lives in a separate environment that the plugin builds and maintains on its own.
Calling you by name
A notification that starts with your name registers differently from a "task completed". So the plugin asks, when you install it, what you want it to call you. Leave it empty and it uses the first name on your system account; a dash means no name at all. You can change it later from /plugin.
It also asks for a language: English, Spanish, Portuguese, French, German or Italian. The phrases, Claude's summary and the voice all follow it, and if you leave it empty it uses your system language. The voice can be swapped for any from the Piper catalogue.
It runs on Linux, macOS and Windows
It started out Linux-only, because that is what I use. Getting it onto all three meant solving each one's details: how a file gets locked, what plays a sound, which fallback voice to use, where data is stored.
Windows was the trickiest, because of two things that fail silently. First, the python3 command is often not Python at all but a shortcut that opens the Microsoft Store, so the plugin tries several until it finds one that works. Second, text with accents came out garbled, because Windows does not default to UTF-8 between processes. "Terminé" reached the voice as nonsense.
Try it
Inside Claude Code:
/plugin marketplace add gastonnrodriguez/heydev
/plugin install heydev@heydev
All you need is Python 3.9 or later. The code is at github.com/gastonnrodriguez/heydev, under the Apache 2.0 licence.
It's open source: join in
HeyDev is an open project, and the idea is for it to grow with the people who use it. If you find a bug, if something does not play on your system, or if you have an idea to make it better, open an issue on GitHub. And if you want to get your hands on the code, pull requests are more than welcome.
There are a few places where a hand would really help:
- Testing it on macOS and Windows. I use it every day on Linux; on the other two, every report of how it works for you (and especially how it does not) is gold.
- Voices in other languages. If you find a Piper voice that sounds good in your language, share it in an issue so it can be recommended in the README.
- Ideas for new notifications. Today it speaks when it finishes and when it needs your attention. There are surely other moments when it should talk to you.
It does not have to be anything big: a two-line issue saying "it doesn't play on my machine" already helps.
What I took away
What surprised me most was how it changed the way I work. I no longer look at the terminal to see whether it has finished; I find out when it finishes. I can have two or three agents running and be doing something else.
And the technical decision that paid off most was the smallest one: not adding a model to summarise, but asking the one that was already doing the work to leave the summary hidden in its own reply.
