Skip to content

Latest commit

 

History

History
370 lines (272 loc) · 27.5 KB

File metadata and controls

370 lines (272 loc) · 27.5 KB

CLAUDE.md — narrate

Agent-oriented context for this repo. Read this before making changes. The user-facing docs are in README.md.

What this is

narrate is a provider-agnostic TTS gateway. Single Bun process exposing three interfaces (HTTP, MCP, CLI) over seven TTS providers (ElevenLabs, OpenAI, Gemini, xAI, Fish Audio, Voicebox, system). Designed to drop into any AI coding harness.

Runtime + stack

  • Bun, not Node. .ts files use #!/usr/bin/env bun and rely on Bun-specific APIs (Bun.serve, Bun.stdin). Don't require()/import.meta like in Node — Bun has its own conventions.
  • TypeScript, strict mode (tsconfig.json).
  • Two runtime deps only: @modelcontextprotocol/sdk (MCP server), zod (schemas). Keep this lean — every dep is reviewed.
  • No bundler, no transpile step. Bun runs .ts directly.

Dev loop

bun install
bun run --watch src/server.ts                  # hot-reload server
./node_modules/.bin/tsc --noEmit               # typecheck (no test suite yet)
bun run src/cli.ts verify                      # smoke test against running server

There is no test suite. v1.0 milestone tracks adding one. Until then, tsc --noEmit and narrate verify --test are the gates.

Architecture invariants

These are intentional. Don't "refactor" them without reading why.

  1. Provider interface dispatch, no switches. Every provider implements Provider (src/providers/base.ts). The server resolves via PROVIDER_REGISTRY (src/providers/index.ts). When adding a provider, register it once and the HTTP/CLI/MCP handlers all pick it up.

  2. AudioResult.delegated. Voicebox and system providers play audio themselves (voicebox calls its own /speak, system invokes say). When delegated: true the server skips playback.ts. The HTTP response surfaces this so callers know whether the server played or the provider did.

  3. MCP transport is stateless per request. WebStandardStreamableHTTPServerTransport is single-use. createMcpFetchHandler builds a fresh McpServer + transport for every request. Reusing it throws "Stateless transport cannot be reused across requests". We hit this; don't go back.

  4. Logger overrides console.*. src/logger.ts patches console.log/console.error to write to a rotating file (logs/narrate.log). LaunchAgent's StandardOutPath/StandardErrorPath only capture pre-init startup output. Don't replace the rotating logger with raw stdout — you'll lose request-level traffic.

  5. /health is the source of truth for paths. repo_dir, logs_dir, voices_path are all reported. Plugins and tooling (e.g. integrations/menubar/narrate.5s.sh) self-locate via /health instead of guessing install paths. When adding new fields users might want to discover, prefer /health over env vars.

  6. voices.json v1/v2 backward-compat. v2 has a provider field per voice; v1 doesn't and is assumed to be system. The loader (src/voices.ts) handles both. Don't break v1 — old voice-server users still rely on it.

  7. Default provider is system, deliberately. Changed from elevenlabs in v0.3.6 so fresh installs work with zero API keys (macOS say / Linux espeak-ng always available). Reverting this re-introduces a broken first-run UX.

  8. Plist PATH is static, not a snapshot. service/launchd/install.sh bakes /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin into the plist (source mode prepends the bun dir). Earlier versions used s|__PATH_VALUE__|$PATH|g which captured the install-time shell PATH — that aged badly (frozen entries, dirs that got deleted). Don't go back to snapshotting. The service installers also support binary mode: set NARRATE_BIN=/path/to/narrate-server to skip bun entirely and run a compiled binary (dist/narrate-server), rendered via __PROGRAM_ARGS__ in the templates. Binary-mode servers self-locate via NARRATE_DIR (compiled detection in src/server.ts).

  9. Narration queue serializes playback. src/server.ts enqueuePlayback guarantees one narration at a time. Voicebox's audio output stops any playback in progress when a new /speak arrives (verified in voicebox's play_audio_to_devices stop_flag). Without the queue, a follow-up 🤖 BOT: auto-voice cut a long on-demand narration mid-word: narrate_speak returns instantly (delegated), the model keeps going, and its next message's auto-voice killed the still-playing audio. Delegated providers hold the slot for an estimated duration (12 chars/s + 400ms) since narrate can't observe the end of playback; responses still return immediately (fire-and-forget) so tools/Escape never block on it. AudioResult.delegated per call still wins — voicebox with return_audio: true plays via playAudio inside its slot.

Voicebox provider — read this before touching it

Voicebox has two concepts that look interchangeable but aren't:

  • Engine = TTS model (Kokoro, Qwen, Chatterbox, TADA, LuxTTS). Each ships preset voices.
  • Profile = a usable voice instance, created from a preset (or cloned).

/speak accepts profile names only. Preset voices must be promoted via POST /profiles. Helper: examples/voicebox-create-profile.sh.

Naming gotcha: the Qwen engine is qwen_custom_voice (underscores). The hyphenated form qwen-customvoice is accepted by POST /profiles but rejected by /speak. Always use underscores.

/speak ignores profile.language and defaults to language: "en". The voicebox provider auto-resolves and passes profile.language (cached 60s) so a Spanish-trained Dora speaks Spanish without extra config. Override per-call via providerConfig.language (POST body), --language (CLI), or pin in voices.json.

Kokoro voices are multilingual at the model level — they're style vectors, not language-locked. A Bella profile (en-trained) asked to speak language: "es" produces real Spanish phonetics with Bella's timbre. This is the canonical way to make any Kokoro voice speak any of its 8 languages.

SwiftBar plugin — read before changing it

  • Refresh: URL scheme, not signals. open "swiftbar://refreshallplugins". SwiftBar does not handle pkill -USR1. We learned this the hard way.
  • Plugin is cp'd, not symlinked. SwiftBar resolves BASH_SOURCE relative to the plugin location. The plugin references its helpers via absolute path back to the repo ($REPO_ROOT/integrations/menubar/narrate-menubar-*.sh). Symlinking the plugin breaks helper resolution.
  • Don't put .sh files in the SwiftBar plugin dir other than the plugin itself. SwiftBar treats every .sh as a plugin and renders a stray "?" icon for anything that doesn't print menu output.
  • Login Items registration via osascript. integrations/menubar/install.sh adds SwiftBar to macOS Login Items via System Events. May silently fail if the user denied permissions — script tolerates this.
  • Menu is a single python3 pass over /health (narrate.5s.sh), English by default with an EN/ES toggle at the bottom (persisted in ~/.config/narrate/menubar.json). Sections: Providers (per-provider ✅/⚪ + API key entry/removal via POST /keys; clicking a configured non-active provider switches the active provider via select mode of the config helper), Voces (two pickers — narrate and session 🤖 BOT: — both drawing ONLY from the active provider's real voice list, no voices.json presets), test buttons (speak only, no config change), service, log tail. Current pair is read from /health (default_provider/default_voice + auto_provider/auto_voice), never from config.json directly — /health is the source of truth.
  • One active provider, two global voices. default_* = on-demand narration, auto_* = the 🤖 BOT: session voice. auto_voice: null + auto_provider: null means "use same as narrate" — the menu renders that as a checked Use same as narrate item. Setting a voice goes through narrate-menubar-config.shPOST /config, which persists to ~/.config/narrate/config.json and applies in memory (no restart). Switching the active provider resets default_voice to that provider's default and clears the auto pair (both voices stay on the same provider).
  • No voice presets in the menu. The old voices.json preset list (fred, iris, kai, espanol…) was removed — those names were stale and didn't reflect the real voicebox profiles. The menu lists raw provider voices plus the live voicebox profiles from 127.0.0.1:17493/profiles (that's where a user-added profile like Santa shows up).
  • Voice catalogs are live, not curated. ElevenLabs voices are fetched from /v1/voices with the account key from ~/.env (real names — ElevenLabs renamed premade voices, e.g. EXAVITQu4vr4xnSDxMaL is now "Sarah", not "Bella"). Fish Audio models are fetched paginated (the public list is 1000+ and churns) and cached at ~/.cache/narrate/fish-models.json for 15 min, otherwise a previously-selected voice falls out of the window and the header shows its raw id. Both fetches use an unverified SSL context — system python's cert store can't verify api.fish.audio/api.elevenlabs.io (CERTIFICATE_VERIFY_FAILED).
  • Headers show voice NAMES, not ids. voice_display_name() resolves the stored voice id against the catalog label (trimmed at · and -) for the top bar and section headers. Picker rows keep passing the raw id (param2) — only display changes.
  • Credits are sub-rows, not inline. ----💳 <credits> under each configured provider that reports them (ElevenLabs chars, Fish package balance) — keeps the provider row short.
  • Big catalogs are grouped by language, not searched. SwiftBar's plugin API has NO text-input field (a native osascript dialog was tried and rejected as clunky). voice_list() renders multilingual catalogs >12 voices as per-language submenus (----English (308)------🗣 ...), flat otherwise. Requires langs metadata on catalog entries (fish + voicebox carry it; elevenlabs/openai/xai don't). voice_display_name() resolves the stored voice id against the catalog label (trimmed at · and -) for the top bar and section headers. Picker rows keep passing the raw id (param2) — only display changes.
  • SwiftBar renders ONE clickable item per line — the first | starts the item's params (see MenuLineParameters.swift in SwiftBar source). Two separately clickable buttons on the same row are NOT possible; the Language section at the bottom is one row per language (🇬🇧 English / 🇪🇸 Español), the active one checked=true, and each row runs narrate-menubar-lang.sh which persists the choice and refreshes the menu.
  • POST /keys syncs the launchd user domain. It writes ~/.env (0600) AND calls launchctl setenv/unsetenv for each key. Keys present only in the launchd domain (e.g. launchctl setenv by the user) are visible to /health as configured but are NOT in ~/.env — removing them via the menu clears both, and adding a key survives server restarts via the domain. Don't drop the launchctl sync or menu-entered keys get shadowed by the domain env on restart.
  • Pair order trap: /health and POST /config use (provider, voice) tuples. The test buttons pass (voice, provider) as param1/param2 to narrate-menubar-speak.sh. Unpack accordingly — this was inverted once and produced xai ara test calls with swapped args.
  • Config helper modes: narrate VOICE PROVIDER, auto VOICE PROVIDER, auto-same (clear session pair), select VOICE PROVIDER (switch active provider — also resets default_voice to the given voice and clears the auto pair). The menu passes the provider's default voice for select unless the current voice already belongs to that provider.

OpenCode plugin — read this before changing it

The plugin lives at integrations/opencode/. Two files (the skill is canonical, copied from skills/narrate/ — see "Canonical narrate skill" below):

  • narrate.js — plugin (~/.config/opencode/plugins/narrate.js at install time).
  • install.sh — copies the plugin + the canonical skill tree, manages @opencode-ai/plugin in package.json, and offers the AGENTS.md convention.

Plugin architecture:

  • Uses @opencode-ai/plugin SDK. Must be .js not .ts — OpenCode's compiled binary only loads JS from plugins/ (plural; the old singular plugin/ dir is silently ignored).
  • Hooks into message.part.updated event (fires during streaming). This carries the full text part so we can forward it to narrate incrementally. Tracks spoken part IDs in a Set to avoid re-speaking on subsequent updates.
  • session.idle hook was tested but either doesn't fire per-turn or the SDK call failed silently — do not switch back to it.
  • Exposes a custom tool narrate_speak via the tool() helper (not a plain object — zod schema parsing breaks without tool()).
  • All calls silently catch exceptions — TTS downtime never breaks the agent.

The 🤖 BOT: convention:

  • The companion skill (SKILL.md) injects instructions into the system prompt teaching the AI to append 🤖 BOT: [<15 words] to every response.
  • The plugin listens for this marker: everything after it is extracted and sent to narrate.
  • This is the same marker convention used by Claude Code's stop hook — intentional, so both harnesses use the same pattern.

On-demand narration:

  • The narrate_speak tool accepts any text and sends it to the narrate server.
  • Triggers: "narra tu respuesta", "narrate", "read aloud", "read that".
  • The generated narration is returned as base64 WAV in the tool response so OpenCode can display playback controls.

Voice config:

  • Voices are resolved from /health — one kind per target: session (🤖 BOT marker) and narrate (on-demand narrate_speak). The plugin asks the server for the voice of the right kind, never guesses IDs. When the session voice is unset it falls back to the narrate voice (server-side auto_* null semantics).
  • Override via env var: NARRATE_OPENCODE_VOICE=<preset_name> (any key from voices.json) — wins over the server pair for the narrate kind.

Files at install destination:

  • ~/.config/opencode/plugins/narrate.js
  • ~/.config/opencode/skills/narrate/SKILL.md
  • ~/.config/opencode/package.json (@opencode-ai/plugin added as dep)

Things that didn't work:

  • session.idle event for auto-voice. Doesn't fire per-turn in practice.
  • ❌ Sending voice field with xAI raw ID. ara is the server default wire format, not a voices.json preset.
  • ❌ Plain object for tool definition. Must use tool() helper from @opencode-ai/plugin.

Dev loop (no build step — JS loads directly):

# edit, copy, restart
vim integrations/opencode/narrate.js
cp integrations/opencode/narrate.js ~/.config/opencode/plugin/narrate.js
# restart OpenCode

Plugin errors surface in OpenCode's terminal (stderr). For skill changes, edit the canonical skills/narrate/ and re-run bash integrations/opencode/install.sh (it cp -Rs the tree to ~/.config/opencode/skills/narrate/).

Pi extension — read this before changing it

The extension lives at integrations/pi/. Pi package structure:

  • package.json — pi manifest (pi key with extensions + skills + image).
  • extensions/narrate.ts — the extension.
  • skills/narrate/SKILL.md — companion skill (same convention, loadable as /narrate).
  • install.sh — manual installer (bash install.sh or bash install.sh --pi).

Extension architecture:

  • No external SDK needed. Uses Pi's native ExtensionAPI: pi.on(), pi.registerTool(), Type from typebox.
  • Auto-voice via message_end (fires once per finalized assistant message). No dedup needed — unlike OpenCode's streaming message.part.updated which fires multiple times per part.
  • System prompt injected via before_agent_start. Guards against duplication (event.systemPrompt.includes("🤖 BOT:")) because the user's AGENTS.md or another extension may already have it.
  • Tool registered via pi.registerTool({...}) with Type.Object({ text: Type.String({...}) }). No tool() helper needed — Pi doesn't have OpenCode's zod-vs-plain-object footgun.
  • All calls silently catch exceptions — TTS downtime never breaks the agent.

The 🤖 BOT: convention:

  • Injected into the system prompt by the extension, not just the skill. The skill is documentation-only (survives /reload, discoverable via /narrate).
  • Same marker regex as OpenCode: MARKER_REGEX = /\u{1F916}\s*BOT:\s*(.+?)(?:\n|$)/u.

On-demand narration:

  • narrate_speak tool with promptSnippet and promptGuidelines for the system prompt.
  • Same triggers: "narra", "narrate", "read aloud", "narra tu respuesta".

Voice config:

  • Same /health kind resolution as the OpenCode plugin: serverVoice("session") for the 🤖 BOT marker, serverVoice("narrate") for narrate_speak. Falls back to the narrate voice when the session voice is unset.
  • NARRATE_PI_VOICE env var (mirrors NARRATE_OPENCODE_VOICE).
  • Auth header: X-Narrate-Client-Id: pi (per-harness client ID for log filtering).

Files at install destination:

  • ~/.pi/agent/extensions/narrate.ts
  • ~/.pi/agent/skills/narrate/SKILL.md
  • Or managed via pi install (reads package.json → writes to ~/.pi/agent/settings.json).

Things that didn't work / design decisions:

  • turn_end event for auto-voice. message_end is the right hook — turn_end fires after tool results, message_end fires right when the assistant message is finalized, giving faster narration.
  • ❌ Separate skill-only approach (like OpenCode). Pi's extension API is rich enough that registering everything in one file is cleaner. The separate SKILL.md is documentation, not the injection mechanism.
  • message_update for streaming TTS. Narrate works in full sentences. message_end is the right boundary.

Dev loop:

# edit extension
vim integrations/pi/extensions/narrate.ts

# test in print mode (no TUI, fast)
cd /tmp && pi -p --no-builtin-tools \
  -e ~/Documents/GitHub/narrate/integrations/pi/extensions/narrate.ts \
  "di algo breve"

# test with voice override
NARRATE_PI_VOICE=researcher pi -p --no-builtin-tools \
  -e ~/Documents/GitHub/narrate/integrations/pi/extensions/narrate.ts \
  "di algo"

# check narrate logs
tail -f ~/Documents/GitHub/narrate/logs/narrate.log | grep client=pi

# for interactive testing, install globally then restart pi
bash integrations/pi/install.sh
# or: pi install ~/Documents/GitHub/narrate/integrations/pi

Auto-voice injection — the load-bearing principle

Auto-voice only fires if the model emits the 🤖 BOT: marker every turn. A skill loads on demand, so it CANNOT guarantee that. The marker convention must live in the harness's always-on context:

Harness Always-on injection Mechanism
Pi extension before_agent_start → system prompt (guarded)
Codex install.sh appends to ~/.codex/AGENTS.md
OpenCode install.sh appends a managed block to ~/.config/opencode/AGENTS.md
Claude Code install.sh appends a managed block to ~/.claude/CLAUDE.md

Before v0.4 OpenCode + Claude Code relied only on the skill — auto-voice silently didn't fire for fresh users. Don't regress this: a skill is a complement, never the injection mechanism. The shared convention text is skills/narrate/assets/convention.md.

Canonical narrate skill — read this before changing it

There is one skill source: skills/narrate/ (SKILL.md + scripts/detect.sh

  • references/{providers,setup,troubleshooting}.md + assets/convention.md). The Claude Code and OpenCode installers copy this whole tree into the harness skills dir (cp -R). Don't fork per-harness skill copies again — they drift (we deleted the old integrations/opencode/SKILL.md and the Claude Code copy for exactly this reason).
  • The skill does two jobs: guided setup/onboarding (OS detect → pick providers → preview voices → write config) and on-demand narration reference.
  • references/providers.md holds time-sensitive external facts (voice lists, models, playground URLs to preview voices). Date-stamped; re-verify if a URL 404s. Voice/model lists were verified 2026-07-31.
  • Pi still bundles its own skills/narrate/SKILL.md because pi install reads the skill from inside the package dir. That's the one remaining duplicate; keep it in sync with the canonical SKILL.md or migrate Pi to copy canonical.
  • Evals live in skills/narrate/evals/evals.json. The eval workspace (skills/narrate-workspace/) is dev-only — do NOT commit it.

Claude Code installer — read this before changing it

integrations/claude-code/install.sh is one-command and idempotent. It:

  1. Registers the MCP server via claude mcp add (skips if present).
  2. Copies the Stop hook to ~/.claude/hooks/narrate-stop-hook.ts.
  3. Merges the Stop hook into ~/.claude/settings.json via bun/node (never clobbers existing hooks; guarded by an includes("narrate-stop-hook") check).
  4. Copies the canonical skill.
  5. Offers to append the 🤖 BOT: managed block to ~/.claude/CLAUDE.md (asks on a TTY; --convention / --no-convention to bypass; non-interactive runs skip it).

Don't go back to the old "print JSON, user pastes it manually" flow — the whole point is zero manual editing, on par with the OpenCode/Pi installers.

Codex integration — read this before changing it

integrations/codex/install.sh registers narrate as a streamable-HTTP MCP server in ~/.codex/config.toml (idempotent append guarded by grep '^\[mcp_servers\.narrate\]') and appends the voice convention to ~/.codex/AGENTS.md. Codex supports url + http_headers under [mcp_servers.NAME] (verified 2026-06-06). Codex has no stop-hook, so auto-voice works by the agent calling the speak tool itself at end of turn (the AGENTS.md teaches this). narrate exposes streamable HTTP only — don't try to register a stdio command for Codex.

Windows / system provider — read this before changing it

  • src/providers/system.ts now supports win32 via PowerShell System.Speech.Synthesis (SAPI). Text + voice are passed via env vars (NARRATE_TEXT / NARRATE_VOICE), never interpolated into the PS script — that avoids quote/injection bugs. SAPI rate is -10..10, not WPM (computeSapiRate maps it).
  • Packaging is Scoop, mirroring the Homebrew tap: packaging/scoop/narrate.json (manifest) installs from a bucket repo felores/scoop-narrate that must be created (a repo with bucket/narrate.json). The manifest depends on bun, downloads the tag tarball, runs bun install, and generates narrate.cmd / narrate-server.cmd wrappers (relative %~dp0 paths so updates don't break shims). No brew services equivalent — use Task Scheduler (see packaging/scoop/README.md).

Release workflow

When shipping a new version:

# 1. bump
edit package.json version
edit CHANGELOG.md (new section at top)
edit README.md (Roadmap row)

# 2. commit + tag + push
git add ...
git commit -F /tmp/msg.txt           # use -F if message contains words
                                     #   the parent .claude deny_check blocks
                                     #   (reboot, shutdown, etc.)
git tag vX.Y.Z -m "..."
git push origin main --tags

# 3. bump Homebrew tap
curl -sL https://github.com/felores/narrate/archive/refs/tags/vX.Y.Z.tar.gz \
    -o /tmp/narrate-vXYZ.tar.gz
shasum -a 256 /tmp/narrate-vXYZ.tar.gz
edit /opt/homebrew/Library/Taps/felores/homebrew-narrate/Formula/narrate.rb
  → update url + sha256
cd /opt/homebrew/Library/Taps/felores/homebrew-narrate
git commit -m "narrate X.Y.Z — ..."
git push
brew update && brew info narrate     # verify "stable X.Y.Z"

Tap repo: https://github.com/felores/homebrew-narrate, cloned at /opt/homebrew/Library/Taps/felores/homebrew-narrate/.

The user's hook setup at ~/.claude/hooks/deny_check.sh blocks shell commands and commit messages containing certain words (e.g. anything that looks like system shutdown, reboot, curl | bash). Workaround for commit messages: write to a tempfile and git commit -F. For piped curl: download to file then run.

Config / env conventions

  • API keys: ~/.env, not ~/.zshrc. Auto-loaded by src/config.ts via loadDotenv. LaunchAgent / systemd / brew services don't run shell init, so .env is the only path that works for both CLI and service modes.
  • XDG: ~/.config/narrate/config.json (defaults) and ~/.config/narrate/voices.json (presets).
  • Legacy compat: ~/.claude/settings.json env.TTS_PROVIDER and env.NARRATE_VOICE_ID are read for backward-compat with the old in-tree voice-server. Don't remove this shim until v1.0.
  • Env override hierarchy in src/config.ts. CLI flag > POST body > MCP arg > ~/.config/narrate/config.json > NARRATE_* env > ~/.claude/settings.json (legacy) > built-in defaults.

What's NOT in scope

  • Voice cloning, STT, dictation. That's voicebox — narrate just proxies to it. Don't add cloning here.
  • Streaming TTS over WebSocket. Roadmap v0.7. Not implemented yet — current providers buffer the full audio response.
  • Auth. Localhost-only by design. Roadmap v0.8 adds tokens for non-localhost use.
  • MCP server in Stdio mode. We expose Streamable HTTP only. Adding stdio is possible but no harness has asked for it.
  • A Windows menubar plugin. Won't match SwiftBar without a tray app, and the cost-benefit isn't there.

Where to look first

  • Adding a provider → src/providers/base.ts + src/providers/index.ts + an existing provider as template (e.g. openai.ts for cloud, system.ts for local).
  • HTTP route or response shape → src/server.ts.
  • MCP tool → src/mcp.ts.
  • CLI flag → src/cli.ts.
  • Voice resolution → src/voices.ts (preset lookup, v1/v2 compat).
  • Config / env → src/config.ts.
  • Audio playback → src/playback.ts.
  • OpenCode integration → integrations/opencode/ (plugin + installer; skill is canonical).
  • Pi integration → integrations/pi/ (extension + skill + installer).
  • Claude Code integration → integrations/claude-code/ (MCP + hook + installer).
  • Codex integration → integrations/codex/ (MCP config + AGENTS.md + installer).
  • Canonical skill → skills/narrate/ (copied into harness skill dirs by installers).
  • Windows packaging → packaging/scoop/ (manifest + service docs).
  • Log rotation → src/logger.ts.

Things I already tried that didn't work

(So you don't waste cycles trying them again.)

  • ❌ Reusing MCP transport across requests. Throws stateless error.
  • pkill -USR1 SwiftBar to refresh plugins. Use the URL scheme.
  • ❌ Symlinking the SwiftBar plugin. Breaks helper resolution.
  • ❌ Snapshotting $PATH into the plist. Captures stale dirs.
  • default_provider: "elevenlabs". Breaks first-run UX.
  • ❌ Putting helper .sh next to the SwiftBar plugin. Spawns a stray menu icon.
  • qwen-customvoice as engine name in /speak calls. Use qwen_custom_voice.
  • ❌ Trusting /speak to use profile.language. It defaults to en — pass language explicitly.
  • ❌ Relying only on a skill for the 🤖 BOT: convention. Skills load on demand; auto-voice needs it in always-on context (CLAUDE.md/AGENTS.md/system prompt).
  • ❌ Per-harness skill copies. They drift — there's one canonical skills/narrate/ that installers copy.
  • ❌ Interpolating user text into the Windows PowerShell SAPI command. Pass via NARRATE_TEXT/NARRATE_VOICE env vars to avoid injection.
  • ❌ Blaming IDE Escape for cut-off narrations. The request was already received in full by the server (it doesn't wire client aborts to providers) — the real killer was the next auto-voice /speak preempting voicebox playback. Fixed with the narration queue; don't "fix" this client-side.