Gives Claude Code a voice: after each reply, speaks a one- or two-sentence gist through a local Kokoro voice, shortened by a local Ollama model.

After each reply, keryx says its gist out loud: a local model writes it, a local voice speaks it.
Gives Claude Code a voice. After each reply, keryx says its gist out loud in one or two sentences: what happened, and what Claude needs from you. Everything runs locally and costs no tokens.
Stop hook sends the reply to a background daemon and returns at once.gemma3:4b), which rewrites it as one or two sentences.It also speaks permission prompts ("I need your permission to use Bash"), and stops talking when you send a new prompt. One daemon serves every Claude session on the machine, so sessions never talk over each other; a line starts with the repo's name when it comes from a different repo than the last one.
Audio goes through Windows because WSLg's PulseAudio sink suspends when idle and then drops or hangs streams (microsoft/wslg#1392). Audio files rotate through 8 slots in C:\Windows\Temp\keryx and are deleted when the daemon exits.
powershell.exe)ollama pull gemma3:4bclaude plugin marketplace add ajbarea/techne
claude plugin install keryx@techne
Then restart Claude Code. The first prompt after install builds the environment and downloads the models, so the first reply can take several minutes to be spoken; later replies start speaking in about 1 to 2 seconds.
| Command | What it does |
|---|---|
/keryx off | Stops speech, shuts the daemon down and unloads the summarizer, freeing about 4.4 GB of VRAM |
/keryx on | Turns speech back on; the next prompt starts the daemon |
/keryx status | Shows the settings and whether the daemon is running |
/keryx again | Says this terminal's last line again |
/keryx:pronounce | Teaches keryx how to say a word; Claude also uses it when you say "X should sound like Y" |
/keryx is a mod command (Claude Code 2.1.287 or later): it runs at once, without a Claude turn, even while Claude is working. On an older Claude Code it is an unknown command; run keryx on, off, again or status in a shell.
Typing or dictating "say that again" (or "come again?", "I didn't catch that") replays the last line without sending the prompt to Claude. keryx pronounce ajsoftworks AJ soft works sets a pronunciation from the shell, keryx pronounce lists them, and keryx pronounce WORD forgets one; they are kept in ~/.config/keryx/pronounce.json. A saying between slashes is phonemes, for sounds English spelling cannot reach: keryx pronounce techne /tˈexni/.
Each terminal speaks in its own voice, through /clear and resume too. A repo keeps its voice across restarts, a second terminal open in the same repo borrows another, and once the stock voices run out new terminals get blends of two. keryx voices [N] plays the first N voices in the catalogue, stock voices first.
While keryx speaks, Spotify drops to a quarter of its volume and comes back a second after the last line; set duck_apps to turn down other apps instead.
Turn keryx off before a long local-LLM run: Ollama sizes GPU offload when a model loads, so a large model loaded beside keryx can end up partly on the CPU.
If keryx goes silent, check ~/.cache/keryx/daemon.log: mci error 326 means Windows has no audio output device, for example because the speakers are off. Nothing needs restarting; the next reply plays once a device is back.
~/.config/keryx/config.json, or an environment variable named KERYX_<FIELD>:
| Field | Default | Meaning |
|---|---|---|
enabled | true | Speak at all |
voice | af_heart | Any Kokoro voice; the first voice handed out |
distinct_voices | true | Give each terminal its own voice; false speaks every terminal in voice |
speed | 1.0 | Speaking rate |
loudness | -16.0 | Loudness (LUFS) every sentence is brought to before it plays |
model | gemma3:4b | Ollama model that shortens replies; empty to speak the opening sentences instead |
ollama_host | http://localhost:11434 | Ollama server |
duck_apps | ["Spotify"] | Windows process names turned down while keryx speaks; [] for none |
duck_ratio | 0.25 | The share of their volume ducked apps keep |
audio_dir | /mnt/c/Windows/Temp/keryx | Where WAVs are written; must be on a Windows drive |
The daemon logs to ~/.cache/keryx/daemon.log, readable only by you, and keeps one older file of about 1 MB. Each plugin version runs in its own venv under ~/.cache/keryx/; one whose checkout has been removed is deleted the next time keryx runs. The unversioned ~/.cache/keryx/venv from 0.5.0 and earlier is never deleted by keryx; remove it by hand once no daemon runs from it. config.json holds only what you or keryx on/off set; a file that cannot be read is ignored, and on/off keep it as config.json.bad.
make lint # ruff format --check, ruff check, ty
make test # pytest with coverage
make docs-build # build the docs site strictly
The documentation site covers install, commands and settings. docs/design.md records the design decisions and the measurements behind them; eval/ holds the summarizer evaluation.
A keryx (κῆρυξ) was a herald in ancient Greece: the one who carried a message and announced it aloud, briefly, to the people it concerned.
hooks/register.ts 40 lines1import type { Register } from 'claude-code'
2
3// The keryx CLI subcommands /keryx runs, and the line each shows in /keryx's usage.
4export const SUBCOMMANDS: Record<string, string> = {
5 on: 'turn speech back on; the next prompt starts the daemon',
6 off: 'stop speech, shut the daemon down and free its VRAM',
7 again: "say this terminal's last line again",
8 status: 'show the settings and whether the daemon is running',
9}
10const USAGE = ['Usage: /keryx on | off | again | status', ...Object.entries(SUBCOMMANDS).map(([k, v]) => ` ${k}: ${v}`)].join('\n')
11// `keryx on` may build the venv on its first run, which takes minutes.
12const RUN_TIMEOUT_MS = 10 * 60_000
13
14export const register: Register = on => {
15 on('session.start', async ($, e, next) => {
16 // Immediate, so /keryx off cuts speech while Claude is still working.
17 await $.command.register({
18 name: 'keryx',
19 description: 'keryx speech: on, off, again or status',
20 argumentHint: '[on|off|again|status]',
21 immediate: true,
22 })
23 return next(e)
24 })
25
26 on('command.run', { command: 'keryx' }, async ($, e) => {
27 const sub = e.args.trim()
28 if (!Object.hasOwn(SUBCOMMANDS, sub)) return { text: USAGE }
29 let run
30 try {
31 run = await $.process.run([`${$.plugin.root}/bin/keryx`, sub], { timeoutMs: RUN_TIMEOUT_MS })
32 } catch (err) {
33 return { text: `keryx ${sub} failed: ${err instanceof Error ? err.message : String(err)}`, exitCode: 1 }
34 }
35 const out = [run.stdout.trim(), run.stderr.trim()].filter(Boolean).join('\n')
36 // exitCode is what `claude -p "/keryx off"` exits with; an interactive session ignores it.
37 return { text: out || `keryx ${sub} exited ${run.exitCode}`, exitCode: Math.min(Math.max(run.exitCode, 0), 255) }
38 })
39}
40