<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kelly</title>
    <description>The latest articles on DEV Community by Kelly (@sodiumsun).</description>
    <link>https://dev.to/sodiumsun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3699340%2F87a0212e-ff87-4b51-98db-c3c1c72baef7.jpeg</url>
      <title>DEV Community: Kelly</title>
      <link>https://dev.to/sodiumsun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sodiumsun"/>
    <language>en</language>
    <item>
      <title>Make Claude Code talk back with a text-to-speech Stop hook</title>
      <dc:creator>Kelly</dc:creator>
      <pubDate>Wed, 14 Oct 2026 14:33:36 +0000</pubDate>
      <link>https://dev.to/sodiumsun/make-claude-code-talk-back-with-a-text-to-speech-stop-hook-5cc3</link>
      <guid>https://dev.to/sodiumsun/make-claude-code-talk-back-with-a-text-to-speech-stop-hook-5cc3</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm Kelly, and I work on Heard (heard.dev), a voice layer for coding agents. Most of this post is the free, do-it-yourself way, because for one session that is all you need. Heard comes up near the end as one option.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Claude Code CLI listens but does not speak. Its &lt;code&gt;/voice&lt;/code&gt; command is dictation: you talk and it types your prompt. The CLI has no spoken output, so if you want to hear when a task is done, you wire it up yourself. It takes one hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stop hook
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Stop&lt;/code&gt; event fires when Claude finishes responding. Its JSON input on stdin includes &lt;code&gt;last_assistant_message&lt;/code&gt;, the text of Claude's final reply. Pipe that into macOS &lt;code&gt;say&lt;/code&gt;. Add this to &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jq -r '.last_assistant_message // empty' | head -c 600 | say"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"async"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need &lt;code&gt;jq&lt;/code&gt; (&lt;code&gt;brew install jq&lt;/code&gt;). The field name and hook shape are checked against the Stop section of the Claude Code hooks reference (October 2026).&lt;/p&gt;

&lt;p&gt;Two details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;head -c 600&lt;/code&gt;&lt;/strong&gt; caps the length. Final replies can be long reports, and &lt;code&gt;say&lt;/code&gt; reads every word.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;"async": true&lt;/code&gt;&lt;/strong&gt; runs the hook in the background, so Claude Code carries on while the voice is still talking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Linux, replace &lt;code&gt;say&lt;/code&gt; with &lt;code&gt;spd-say -e&lt;/code&gt; from the &lt;code&gt;speech-dispatcher&lt;/code&gt; package.&lt;/p&gt;

&lt;p&gt;If you only want a sound, not the words, use &lt;code&gt;afplay /System/Library/Sounds/Glass.aiff&lt;/code&gt; as the command instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three small upgrades
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Say which session finished.&lt;/strong&gt; Every hook input includes &lt;code&gt;cwd&lt;/code&gt;, so &lt;code&gt;jq -r .cwd | xargs basename | say&lt;/code&gt; speaks the folder name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hear failures.&lt;/strong&gt; &lt;code&gt;Stop&lt;/code&gt; does not fire when a turn ends on an API error. Claude Code fires &lt;code&gt;StopFailure&lt;/code&gt; instead, so add the same command there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debug a silent hook.&lt;/strong&gt; Run &lt;code&gt;/hooks&lt;/code&gt; inside Claude Code to see what loaded. A JSON syntax error in settings.json is the usual cause.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The same idea in Codex
&lt;/h2&gt;

&lt;p&gt;Codex has a &lt;code&gt;notify&lt;/code&gt; setting in &lt;code&gt;~/.codex/config.toml&lt;/code&gt;. It runs a command with one JSON argument when a turn completes, with &lt;code&gt;type&lt;/code&gt; set to &lt;code&gt;agent-turn-complete&lt;/code&gt; and the reply in &lt;code&gt;last-assistant-message&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent-turn-complete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last-assistant-message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Codex finished.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Popen&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;say&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save it as &lt;code&gt;~/.codex/speak.py&lt;/code&gt;, then point Codex at it with its absolute path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;notify&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"python3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/Users/you/.codex/speak.py"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;subprocess.Popen&lt;/code&gt; returns straight away, so Codex is not blocked while it speaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  When several sessions get noisy
&lt;/h2&gt;

&lt;p&gt;For one session, stop here. The hook is free and local. The trouble starts with three or four terminals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It reads, it does not summarise.&lt;/strong&gt; &lt;code&gt;say&lt;/code&gt; reads markdown, file paths and code exactly as written. A reply full of paths is hard to follow by ear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every event sounds the same.&lt;/strong&gt; "Done", "tests failed" and "waiting for approval" arrive as one voice reading text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions talk over each other&lt;/strong&gt;, and "finished" does not tell you which one needs you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few tools go further. Each description comes from the project's own README, read this week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Aloud&lt;/strong&gt; (&lt;a href="https://github.com/softcane/aloud" rel="noopener noreferrer"&gt;softcane/aloud&lt;/a&gt;, MIT) speaks a short gist of each Claude Code and Codex reply on your Mac (about 240 characters by default) with local Kokoro speech, and &lt;code&gt;aloud full&lt;/code&gt; reads the whole reply. You turn it on per session with &lt;code&gt;aloud on&lt;/code&gt;; questions take priority over completion messages and routine tool calls stay silent. It wins if you want something free, local and per-session. It needs Python 3.11 or 3.12 and Homebrew.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VoiceMode&lt;/strong&gt; (&lt;a href="https://github.com/mbailey/voicemode" rel="noopener noreferrer"&gt;mbailey/voicemode&lt;/a&gt;, MIT) is for spoken back-and-forth conversations with Claude Code. It can run locally with Whisper and Kokoro or use OpenAI's cloud, and it runs on Linux, macOS and Windows. It wins if you want to talk with one session, or you are not on a Mac.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heard (heard.dev)&lt;/strong&gt; is the one I work on, a Mac app (macOS 14 or later) for Claude Code and Codex. It speaks a sentence or two about what changed rather than the reply itself. Questions, blockers and failures come through first, and routine progress from parallel sessions is batched into short summaries that start with the project name. Focus mode keeps routine progress quiet and speaks only for questions, blockers and failures. On the Power plan you can answer back by voice. The engine is open source under Apache 2.0 with a local Kokoro voice and no account needed; the downloadable app is a managed build with a 14-day Power trial at signup, no card required. Where the others win: Heard is Mac only, and after the 14-day trial the app needs a paid plan (Pro or Power, on heard.dev/pricing); the free option is building the open-source engine from source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a longer side-by-side of every Claude Code text-to-speech option &lt;a href="https://heard.dev/compare/claude-code-text-to-speech?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=stop-hook-tts" rel="noopener noreferrer"&gt;on heard.dev&lt;/a&gt;, and a step-by-step &lt;a href="https://heard.dev/guides/claude-code-sound-when-done?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=stop-hook-tts" rel="noopener noreferrer"&gt;sound-when-done guide&lt;/a&gt; with the hook variations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One session, you just want to know it is done: the Stop hook or the Codex &lt;code&gt;notify&lt;/code&gt; script above.&lt;/li&gt;
&lt;li&gt;Free, local speech in the sessions you choose, on a Mac: Aloud.&lt;/li&gt;
&lt;li&gt;A spoken conversation with Claude, on any OS: VoiceMode.&lt;/li&gt;
&lt;li&gt;Several Claude Code and Codex sessions, with short summaries and replies by voice: Heard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build your own variation of the hook, I'd like to see it in the comments.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>codex</category>
      <category>macos</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
