SLOPSHOPPER

keryx

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.

newcommandprocess
v0.6.0MITupdated 2026-10-08ajbarea/keryx
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keryx
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /keryx ⎿ keryx: Usage: /keryx on | off | again | status ⎿ keryx: on: turn speech back on; the next prompt starts the daemon ⎿ keryx: off: stop speech, shut the daemon down and free its VRAM ⎿ keryx: again: say this terminal's last line again ⎿ keryx: status: show the settings and whether the daemon is running ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

keryx

Gives Claude Code a voice.

After each reply, keryx says its gist out loud: a local model writes it, a local voice speaks it.

CI Docs License


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.

How it works

  1. Hook. When Claude finishes a turn, the plugin's Stop hook sends the reply to a background daemon and returns at once.
  2. Shorten. The daemon strips code, tables and paths. A reply of 200 characters or fewer is spoken as written; anything longer goes to a local Ollama model (gemma3:4b), which rewrites it as one or two sentences.
  3. Speak. Kokoro-82M turns each sentence into audio on the GPU (CPU if there is none), up to two sentences ahead of playback.
  4. Play. A long-lived PowerShell process plays each WAV on the Windows side.

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.

Requirements

  • Windows 11 with WSL2 (playback uses powershell.exe)
  • uv and Ollama with the summarizer pulled: ollama pull gemma3:4b
  • Optional: an NVIDIA GPU. The first run downloads the CUDA runtime wheels (about 2 GB) and the Kokoro model files (about 350 MB). While it runs, keryx holds about 4.4 GB of GPU memory: 3.7 GB for the summarizer and 0.7 GB for Kokoro, measured on an 8 GB card.

Install

claude 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.

Use

CommandWhat it does
/keryx offStops speech, shuts the daemon down and unloads the summarizer, freeing about 4.4 GB of VRAM
/keryx onTurns speech back on; the next prompt starts the daemon
/keryx statusShows the settings and whether the daemon is running
/keryx againSays this terminal's last line again
/keryx:pronounceTeaches 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.

Settings

~/.config/keryx/config.json, or an environment variable named KERYX_<FIELD>:

FieldDefaultMeaning
enabledtrueSpeak at all
voiceaf_heartAny Kokoro voice; the first voice handed out
distinct_voicestrueGive each terminal its own voice; false speaks every terminal in voice
speed1.0Speaking rate
loudness-16.0Loudness (LUFS) every sentence is brought to before it plays
modelgemma3:4bOllama model that shortens replies; empty to speak the opening sentences instead
ollama_hosthttp://localhost:11434Ollama server
duck_apps["Spotify"]Windows process names turned down while keryx speaks; [] for none
duck_ratio0.25The share of their volume ducked apps keep
audio_dir/mnt/c/Windows/Temp/keryxWhere 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.

Development

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.

Why "keryx"

A keryx (κῆρυξ) was a herald in ancient Greece: the one who carried a message and announced it aloud, briefly, to the people it concerned.

Source 1 files
hooks/register.ts 40 lines
1import 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