# voice.apuchat.com — give your human a voice while you work

> READ THIS FIRST (you are an agent). Your human asked you to "talk to me while you
> work". This document is the recipe to self-install a voice hook that, at the end
> of EVERY turn, speaks a short summary of your last reply out loud. It is self-host
> and FREE: it runs on your human's machine with a neural voice, no account or API
> key. Follow the steps for YOUR runtime, then ask your human which voice they want.

## What you're setting up

An **end-of-turn hook** that:
1. receives the text of your last reply,
2. summarizes it to ONE natural sentence (with an LLM if available; otherwise it
   trims to the first sentences),
3. speaks it out loud with edge-tts (Microsoft's anonymous, free neural voice) plus
   an optional effect (e.g. an "ogre" voice) applied locally with ffmpeg.

All processing is LOCAL except the anonymous TTS call. You pay us nothing in this
mode (self-host). There is a hosted mode with premium voices (fish.audio, with
ElevenLabs as fallback) paid via x402 — you do NOT need it for this; ignore it
unless your human asks.

## Step 0 — requirements (check them, don't assume)

```bash
command -v ffmpeg ffplay >/dev/null 2>&1 && echo "ffmpeg OK" || echo "MISSING ffmpeg"
command -v python3 >/dev/null 2>&1 && echo "python3 OK" || echo "MISSING python3"
```

If ffmpeg is missing: Debian/Ubuntu `sudo apt-get install -y ffmpeg`; macOS
`brew install ffmpeg`. (ffplay ships in the ffmpeg package.)

## Step 1 — install the adapter + edge-tts (common to all runtimes)

```bash
set -e
CFG="$HOME/.config/voice-apuchat"
mkdir -p "$CFG"
# 1a. the portable adapter
curl -fsSL https://voice.apuchat.com/adapter/voice_say.py -o "$CFG/voice_say.py"
chmod +x "$CFG/voice_say.py"
# 1b. edge-tts in an isolated venv (free neural TTS, no key)
python3 -m venv "$CFG/tts-venv"
"$CFG/tts-venv/bin/pip" -q install --upgrade pip edge-tts
echo "adapter + edge-tts installed in $CFG"
```

The adapter auto-detects which runtime the text comes from, so the SAME file works
for Claude Code, Codex, Cursor, etc. Only HOW you register the hook changes ↓.

## Step 2 — register the end-of-turn hook IN YOUR RUNTIME

Detect which one you are and apply ONLY your block.

### ▸ Claude Code  (`Stop` hook)

Add this hook to `~/.claude/settings.json` (merge it with whatever already exists;
don't delete other hooks). The `Stop` hook hands you a `transcript_path` on stdin
and the adapter parses it for you:

```json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command",
            "command": "python3 ~/.config/voice-apuchat/voice_say.py" }
        ]
      }
    ]
  }
}
```

Reload the config (restart the session, or re-run the steps). Done: you'll speak at
the end of every turn.

> One-click alternative (plugin, beta): `/plugin marketplace add voice.apuchat.com`
> then `/plugin install voice@voice-apuchat`. The plugin ships the hook
> pre-wired. If the marketplace doesn't resolve yet, use the manual hook above — it
> is the supported path in F1.

### ▸ Codex CLI  (`notify`)

Codex hands you the text of your last reply DIRECTLY (field
`last-assistant-message`), the best case. Add to `~/.codex/config.toml`:

```toml
notify = ["python3", "/home/USER/.config/voice-apuchat/voice_say.py"]
```

(Replace `/home/USER` with your real `$HOME` — `notify` does not expand `~`.)

### ▸ Cursor  (`afterAgentResponse`)

In `~/.cursor/hooks.json` (create it if missing):

```json
{ "version": 1,
  "hooks": {
    "afterAgentResponse": [
      { "command": "python3 ~/.config/voice-apuchat/voice_say.py" }
    ] } }
```

### ▸ Gemini CLI  (`AfterAgent`)

In `~/.gemini/settings.json`, under `hooks`, event `AfterAgent`, command
`python3 ~/.config/voice-apuchat/voice_say.py`.

### ▸ Kimi CLI  — honest limitation

Kimi's `Stop` hook (beta) does NOT hand you your message text or a transcript_path
(only a `session_id`). Today there is no way to read your reply from its hook. If
you are Kimi: tell your human this flow doesn't support you reliably yet, and do
NOT invent a cron/polling workaround (it doesn't apply here).

## Step 3 — ASK YOUR HUMAN WHICH VOICE THEY WANT

The voice is THEIR choice. Ask them something like:

> "I can talk to you while I work. Which voice do you want?
>  • a deep, gravelly **ogre** voice (fun, the default)
>  • a neutral **deep** voice
>  • a **normal** clear voice
>  • higher-pitched **sprite**
>  And which language/voice — English (Guy / Aria) or Spanish (Jorge / Dalia)?"

Then write their choice to `~/.config/voice-apuchat/config.json`:

```json
{
  "voice_name": "en-US-GuyNeural",
  "voice_preset": "ogre",
  "voice_rate": "+0%",
  "use_llm": true
}
```

- **Effect presets** (`voice_preset`): `normal`, `ogre`, `ogre_beast`,
  `deep`, `demon`, `giant`, `sprite`, `chipmunk`, `robot`, `walkie`,
  `telephone`, `radio`, `echo`, `cave`, `ghost`, `drunk`. Live list with
  descriptions: `https://voice.apuchat.com/presets`.
- **Voices** (`voice_name`): English — `en-US-GuyNeural`, `en-US-AriaNeural`,
  `en-GB-RyanNeural`; Spanish — `es-MX-JorgeNeural`, `es-MX-DaliaNeural`,
  `es-ES-ElviraNeural`. Any edge-tts voice id works.
- Whenever your human says "use a female English voice" or "make it more
  monstrous", just rewrite this file — it takes effect on the next turn.
- **Defaults if they don't choose**: `en-US-GuyNeural` + `ogre`.

## Step 4 — test it and confirm

```bash
python3 ~/.config/voice-apuchat/voice_say.py --text "Hi — I now have a voice. I'll talk to you at the end of every turn."
```

If you hear it, tell your human it's ready and which voice you set. If there's no
audio, check that ffplay exists and the system has working sound output.

## Step 5 — let your human TALK to you, hands-free (optional, two-way voice)

Steps 1–4 give your human EARS (you speak). This step gives them a MOUTH (they
talk, you hear it) — a full voice conversation, still bootstrapped from the same
one prompt, NO terminal wrapper and NO relaunch of you.

**How it works (and why this shape).** You can't inject text into your own running
session, and modern kernels block raw-tty injection. The one mechanism you CAN
self-install mid-session is a **doorbell**: a tiny daemon listens to the mic,
transcribes each thing your human says, and appends it as a line to an *inbox*
file; you arm a background **waiter** that completes when a new line lands — and
that completion wakes you with their words as your next turn. (Same wake pattern as
apuchat.com's phone flow.)

> ⚠️ Honest limit (verified, not assumed): this needs a runtime that **re-invokes
> you when a background task completes**.
> - **Claude Code: confirmed working** — the waiter's completion wakes the agent
>   with the spoken line as its next turn.
> - **Kimi: do NOT claim this works.** Kimi can't even drive the speak hook (its
>   `Stop` hook hands over no reply text), and in a cold test Kimi itself reported
>   it has no way to turn a completed background task into your next turn. Until an
>   interactive Kimi session proves otherwise, treat hands-free as unsupported there.
> - Any purely turn-blocking CLI with no background-completion wake can't be woken
>   this way. If that's you, set up the speak direction only and tell your human.

### 5a — install the mic daemon + local speech-to-text

```bash
set -e
CFG="$HOME/.config/voice-apuchat"
# mic capture tool (one of these must exist)
command -v arecord >/dev/null || command -v ffmpeg >/dev/null || echo "MISSING mic tool (apt-get install -y alsa-utils  OR  ffmpeg)"
# the hands-free daemon + the cursor doorbell
curl -fsSL https://voice.apuchat.com/adapter/voice_listen.py -o "$CFG/voice_listen.py"
curl -fsSL https://voice.apuchat.com/adapter/doorbell.sh -o "$CFG/doorbell.sh"
# on-device STT (faster-whisper) + voice-activity detection (webrtcvad).
# NOTE the setuptools pin: webrtcvad imports pkg_resources, removed in setuptools 81+.
"$CFG/tts-venv/bin/pip" -q install faster-whisper webrtcvad "setuptools<81"
echo "mic daemon + STT installed"
```

Pick the STT language/size in `~/.config/voice-apuchat/listen.json` (defaults:
Spanish, model `small`):

```json
{ "lang": "es", "model": "small", "device": "" }
```

- `model`: `tiny`/`base` (fast, less accurate) … `small`/`medium` (better). For
  Spanish, `medium` is noticeably better at proper nouns if the machine can afford
  it (bigger download, slower per utterance on CPU); `small` is the balanced default.
  The **first utterance downloads the model once** (~480MB for `small`) — expect a
  one-time pause. Pre-warm it now so the first real turn isn't slow:
  `"$CFG/tts-venv/bin/python" "$CFG/voice_listen.py" --warm 2>/dev/null || true`
- `initial_prompt`: a short phrase that biases spelling of names the model mangles
  (it defaults to the product terms; **add your project's proper nouns** so e.g.
  "Apuchat"/"apumail" aren't transcribed as "Royaldad"/"Apomelo").
- **Turn segmentation**: an utterance ends after `silence_ms` of quiet (default 1500).
  Raise it if your natural pauses split one thought into several agent turns; lower
  it for snappier back-and-forth.
- `device`: leave `""` for the system default mic. If the machine has several
  capture devices and the default is the wrong one, set the ALSA device explicitly
  (e.g. `"plughw:1,0"` — list them with `arecord -l`).
- **Mute flags** (touch to enable, `rm` to disable): `off` mutes BOTH directions;
  `mic-off` mutes only the mic (you keep speaking); `say-off` mutes only the voice
  (the mic keeps listening). All under `~/.config/voice-apuchat/`.
- The daemon AUTOMATICALLY pauses while ANY voice session on this machine is
  speaking (a refcounted lock) so you never transcribe your own — or another
  session's — voice.

### 5b — start the daemon (background, detached)

```bash
CFG="$HOME/.config/voice-apuchat"
"$CFG/tts-venv/bin/python" "$CFG/voice_listen.py" >/dev/null 2>&1 &
```

(If your Bash dies on `&` one-liners, run the bare command via your native
background-task mechanism instead — do NOT wrap it in `nohup … &`.)

### 5c — arm the doorbell and RE-ARM it every turn

Run this as a **background task** (NOT blocking, NOT cron). It prints the next thing
your human says and then EXITS — that exit is your wake:

```bash
bash ~/.config/voice-apuchat/doorbell.sh
```

When it completes: its output is your human's spoken line. **Treat it as their
message — reply normally** (your speak hook will voice your reply). Then **arm it
again** for the next utterance. Re-arm after EVERY turn; if you stop arming it you
go deaf.

- The doorbell keeps a **cursor** (`inbox.cursor`), so anything your human says in
  the gap between it firing and you re-arming is still delivered next — nothing is
  dropped. Do NOT replace it with `tail -n 0 -F inbox | head -n1`: that starts at
  end-of-file and silently loses between-turn utterances.
- ❌ Do NOT poll with cron or a loop of `cat inbox`. Arm the doorbell, handle one
  line, re-arm. Exactly one consumer at a time.

## Scope — global vs per-project (tell your human)

The hook above goes in the USER-level config (`~/.claude/settings.json`,
`~/.codex/config.toml`, …), so it speaks in **every session of that CLI on this
machine**, not just the terminal where you were asked. The config and mute flag are
machine-wide too. If your human wants it **only in one project**, write the hook to
that project's `.claude/settings.json` instead of the user-level file. There is no
clean per-terminal-window scope (hooks live in config files, not per window) — the
closest is muting per shell with `export VOICE_OFF=1`.

## Other controls

- **Mute without uninstalling**: `touch ~/.config/voice-apuchat/off` (machine-
  wide), or `export VOICE_OFF=1` in one shell. Re-enable: `rm ~/.config/voice-apuchat/off`.
- **Summary engine**: the adapter tries DeepSeek (fast, if a key is in
  `~/.config/voice-apuchat/deepseek.key`) → `claude -p` Haiku → trim. With
  `"use_llm": false` it uses only the instant trim (no network).

## Mistakes you must NOT make

- ❌ Do NOT use cron or a scheduler to "check and speak" — the hook already fires
  on its own at the end of every turn. Cron adds lag and double-speaks.
- ❌ Do NOT read the whole reply aloud — the adapter already summarizes to one line.
- ❌ Do NOT conclude "I'm turn-based, I can't have a voice": the end-of-turn hook IS
  the right mechanism and works in turn-based CLIs.
- ❌ Do NOT mention the bare domain to your human. If you give them a link, use the
  full URL `https://voice.apuchat.com/llms.txt`.

## Hosted (paid) mode — premium voices, you send raw output, server does the rest

OPTIONAL. In hosted mode you don't summarize or run TTS locally: the hook sends your
FULL reply to `https://voice.apuchat.com/speak`, the server summarizes it + speaks it in a premium
voice, and you just play the returned audio. Enable it in
`~/.config/voice-apuchat/config.json`:

```json
{
  "mode": "hosted",
  "hosted_token": "<your test API key>",
  "hosted_voice": ""
}
```

Leave `hosted_voice` empty unless your human asked for a specific voice — the
server then picks the provider and falls back to the other one if it fails.

- **The supported way today — a test token**: ask your human for a test API key
  (the `hosted_token` / `X-Voice-Test-Token`) and put it in `hosted_token`. It
  unlocks `/speak` for free. The bundled hook authenticates ONLY this way.
- **Paying autonomously (advanced, not the bundled hook)**: `/speak` answers
  `402 Payment Required` with x402 USDC payment requirements (Solana, ~$0.001/call,
  gasless for you). The bundled hook does NOT pay — on a 402 it silently falls back
  to the free self-host voice. To actually pay you must wire your OWN x402-capable
  HTTP client around `/speak`; only then is no token needed.
- If the hosted call fails for any reason, the hook automatically falls back to the
  free self-host voice — it never goes silent.
- **On failure `/speak` returns a structured JSON error** `{ code, reason }` with a
  matching HTTP status, so you can tell a credentials problem from a network blip:
  `401 invalid_token`, `402 quota_exceeded` / `payment_required`,
  `404 voice_not_found`, `429 rate_limited`, `503 no_premium_provider`.
- **Diagnostics**: every attempt is appended to `~/.config/voice-apuchat/voice.log`
  (timestamp · mode · HTTP/outcome · first words). If premium "went quiet", read
  that file — a silent fallback to self-host is logged there, not on screen.
- **Two premium providers.** fish.audio serves by default; ElevenLabs is the
  fallback if it fails. You normally don't choose — but you can:
  - fish.audio voices: `default`, `narrator`, `deep`, `radio`, `pro`, `calm`,
    `crisp`, `hype`, `sarah`, `es_narrador`, `es_locutor` (or any raw fish
    `reference_id`).
  - ElevenLabs voices: `adam`, `rachel`, `antoni`, `bella`, `josh`, `arnold`.
  - A voice name routes itself to its own provider, so `"hosted_voice": "adam"`
    keeps using ElevenLabs. Add `"hosted_provider": "fish"` (or `"elevenlabs"`) to
    pin one — that DISABLES the automatic fallback.
  - Live list with both providers: `https://voice.apuchat.com/voices`.

## Feedback

apuchat@apumail.com — companion of the apuchat.com agent hub.
