Agent-oriented context for this repo. Read this before making changes. The user-facing docs are in README.md.
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.
- Bun, not Node.
.tsfiles use#!/usr/bin/env bunand rely on Bun-specific APIs (Bun.serve,Bun.stdin). Don'trequire()/import.metalike 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
.tsdirectly.
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 serverThere is no test suite. v1.0 milestone tracks adding one. Until then, tsc --noEmit and narrate verify --test are the gates.
These are intentional. Don't "refactor" them without reading why.
-
Provider interface dispatch, no switches. Every provider implements
Provider(src/providers/base.ts). The server resolves viaPROVIDER_REGISTRY(src/providers/index.ts). When adding a provider, register it once and the HTTP/CLI/MCP handlers all pick it up. -
AudioResult.delegated. Voicebox and system providers play audio themselves (voicebox calls its own/speak, system invokessay). Whendelegated: truethe server skipsplayback.ts. The HTTP response surfaces this so callers know whether the server played or the provider did. -
MCP transport is stateless per request.
WebStandardStreamableHTTPServerTransportis single-use.createMcpFetchHandlerbuilds a freshMcpServer+ transport for every request. Reusing it throws"Stateless transport cannot be reused across requests". We hit this; don't go back. -
Logger overrides
console.*.src/logger.tspatchesconsole.log/console.errorto write to a rotating file (logs/narrate.log). LaunchAgent'sStandardOutPath/StandardErrorPathonly capture pre-init startup output. Don't replace the rotating logger with raw stdout — you'll lose request-level traffic. -
/healthis the source of truth for paths.repo_dir,logs_dir,voices_pathare all reported. Plugins and tooling (e.g.integrations/menubar/narrate.5s.sh) self-locate via/healthinstead of guessing install paths. When adding new fields users might want to discover, prefer/healthover env vars. -
voices.jsonv1/v2 backward-compat. v2 has aproviderfield per voice; v1 doesn't and is assumed to be system. The loader (src/voices.ts) handles both. Don't break v1 — oldvoice-serverusers still rely on it. -
Default provider is
system, deliberately. Changed fromelevenlabsin v0.3.6 so fresh installs work with zero API keys (macOSsay/ Linuxespeak-ngalways available). Reverting this re-introduces a broken first-run UX. -
Plist
PATHis static, not a snapshot.service/launchd/install.shbakes/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bininto the plist (source mode prepends the bun dir). Earlier versions useds|__PATH_VALUE__|$PATH|gwhich 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: setNARRATE_BIN=/path/to/narrate-serverto skip bun entirely and run a compiled binary (dist/narrate-server), rendered via__PROGRAM_ARGS__in the templates. Binary-mode servers self-locate viaNARRATE_DIR(compiled detection insrc/server.ts). -
Narration queue serializes playback.
src/server.tsenqueuePlaybackguarantees one narration at a time. Voicebox's audio output stops any playback in progress when a new/speakarrives (verified in voicebox'splay_audio_to_devicesstop_flag). Without the queue, a follow-up🤖 BOT:auto-voice cut a long on-demand narration mid-word:narrate_speakreturns 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.delegatedper call still wins — voicebox withreturn_audio: trueplays viaplayAudioinside its slot.
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.
- Refresh: URL scheme, not signals.
open "swiftbar://refreshallplugins". SwiftBar does not handlepkill -USR1. We learned this the hard way. - Plugin is
cp'd, not symlinked. SwiftBar resolvesBASH_SOURCErelative 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
.shfiles in the SwiftBar plugin dir other than the plugin itself. SwiftBar treats every.shas a plugin and renders a stray "?" icon for anything that doesn't print menu output. - Login Items registration via osascript.
integrations/menubar/install.shadds 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 viaPOST /keys; clicking a configured non-active provider switches the active provider viaselectmode of the config helper), Voces (two pickers —narrateand session🤖 BOT:— both drawing ONLY from the active provider's real voice list, novoices.jsonpresets), 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 fromconfig.jsondirectly —/healthis the source of truth. - One active provider, two global voices.
default_*= on-demand narration,auto_*= the🤖 BOT:session voice.auto_voice: null+auto_provider: nullmeans "use same as narrate" — the menu renders that as a checkedUse same as narrateitem. Setting a voice goes throughnarrate-menubar-config.sh→POST /config, which persists to~/.config/narrate/config.jsonand applies in memory (no restart). Switching the active provider resetsdefault_voiceto 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.jsonpreset 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 from127.0.0.1:17493/profiles(that's where a user-added profile likeSantashows up). - Voice catalogs are live, not curated. ElevenLabs voices are fetched from
/v1/voiceswith the account key from~/.env(real names — ElevenLabs renamed premade voices, e.g.EXAVITQu4vr4xnSDxMaLis now "Sarah", not "Bella"). Fish Audio models are fetched paginated (the public list is 1000+ and churns) and cached at~/.cache/narrate/fish-models.jsonfor 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 verifyapi.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. Requireslangsmetadata 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 (seeMenuLineParameters.swiftin 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 onechecked=true, and each row runsnarrate-menubar-lang.shwhich persists the choice and refreshes the menu. POST /keyssyncs the launchd user domain. It writes~/.env(0600) AND callslaunchctl setenv/unsetenvfor each key. Keys present only in the launchd domain (e.g.launchctl setenvby the user) are visible to/healthas 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:
/healthandPOST /configuse (provider, voice) tuples. The test buttons pass (voice, provider) asparam1/param2tonarrate-menubar-speak.sh. Unpack accordingly — this was inverted once and producedxai aratest 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 resetsdefault_voiceto the given voice and clears the auto pair). The menu passes the provider's default voice forselectunless the current voice already belongs to that provider.
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.jsat install time).install.sh— copies the plugin + the canonical skill tree, manages@opencode-ai/plugininpackage.json, and offers the AGENTS.md convention.
Plugin architecture:
- Uses
@opencode-ai/pluginSDK. Must be.jsnot.ts— OpenCode's compiled binary only loads JS fromplugins/(plural; the old singularplugin/dir is silently ignored). - Hooks into
message.part.updatedevent (fires during streaming). This carries the full text part so we can forward it to narrate incrementally. Tracks spoken part IDs in aSetto avoid re-speaking on subsequent updates. session.idlehook 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_speakvia thetool()helper (not a plain object — zod schema parsing breaks withouttool()). - 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_speaktool 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— onekindper target:session(🤖 BOT marker) andnarrate(on-demandnarrate_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-sideauto_*null semantics). - Override via env var:
NARRATE_OPENCODE_VOICE=<preset_name>(any key fromvoices.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/pluginadded as dep)
Things that didn't work:
- ❌
session.idleevent for auto-voice. Doesn't fire per-turn in practice. - ❌ Sending
voicefield with xAI raw ID.arais the server default wire format, not avoices.jsonpreset. - ❌ 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 OpenCodePlugin 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/).
The extension lives at integrations/pi/. Pi package structure:
package.json— pi manifest (pikey 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.shorbash install.sh --pi).
Extension architecture:
- No external SDK needed. Uses Pi's native
ExtensionAPI:pi.on(),pi.registerTool(),Typefromtypebox. - Auto-voice via
message_end(fires once per finalized assistant message). No dedup needed — unlike OpenCode's streamingmessage.part.updatedwhich 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({...})withType.Object({ text: Type.String({...}) }). Notool()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_speaktool withpromptSnippetandpromptGuidelinesfor the system prompt.- Same triggers: "narra", "narrate", "read aloud", "narra tu respuesta".
Voice config:
- Same
/healthkind resolution as the OpenCode plugin:serverVoice("session")for the 🤖 BOT marker,serverVoice("narrate")fornarrate_speak. Falls back to the narrate voice when the session voice is unset. NARRATE_PI_VOICEenv var (mirrorsNARRATE_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(readspackage.json→ writes to~/.pi/agent/settings.json).
Things that didn't work / design decisions:
- ❌
turn_endevent for auto-voice.message_endis the right hook —turn_endfires after tool results,message_endfires 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_updatefor streaming TTS. Narrate works in full sentences.message_endis 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/piAuto-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.
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 oldintegrations/opencode/SKILL.mdand 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.mdholds 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.mdbecausepi installreads 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.
integrations/claude-code/install.sh is one-command and idempotent. It:
- Registers the MCP server via
claude mcp add(skips if present). - Copies the Stop hook to
~/.claude/hooks/narrate-stop-hook.ts. - Merges the Stop hook into
~/.claude/settings.jsonvia bun/node (never clobbers existing hooks; guarded by anincludes("narrate-stop-hook")check). - Copies the canonical skill.
- Offers to append the
🤖 BOT:managed block to~/.claude/CLAUDE.md(asks on a TTY;--convention/--no-conventionto 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.
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.
src/providers/system.tsnow supportswin32via PowerShellSystem.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 (computeSapiRatemaps it).- Packaging is Scoop, mirroring the Homebrew tap:
packaging/scoop/narrate.json(manifest) installs from a bucket repofelores/scoop-narratethat must be created (a repo withbucket/narrate.json). The manifest depends onbun, downloads the tag tarball, runsbun install, and generatesnarrate.cmd/narrate-server.cmdwrappers (relative%~dp0paths so updates don't break shims). Nobrew servicesequivalent — use Task Scheduler (seepackaging/scoop/README.md).
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.
- API keys:
~/.env, not~/.zshrc. Auto-loaded bysrc/config.tsvialoadDotenv. LaunchAgent / systemd / brew services don't run shell init, so.envis 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.jsonenv.TTS_PROVIDERandenv.NARRATE_VOICE_IDare read for backward-compat with the old in-treevoice-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.
- 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.
- Adding a provider →
src/providers/base.ts+src/providers/index.ts+ an existing provider as template (e.g.openai.tsfor cloud,system.tsfor 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.
(So you don't waste cycles trying them again.)
- ❌ Reusing MCP transport across requests. Throws stateless error.
- ❌
pkill -USR1 SwiftBarto refresh plugins. Use the URL scheme. - ❌ Symlinking the SwiftBar plugin. Breaks helper resolution.
- ❌ Snapshotting
$PATHinto the plist. Captures stale dirs. - ❌
default_provider: "elevenlabs". Breaks first-run UX. - ❌ Putting helper
.shnext to the SwiftBar plugin. Spawns a stray menu icon. - ❌
qwen-customvoiceas engine name in/speakcalls. Useqwen_custom_voice. - ❌ Trusting
/speakto useprofile.language. It defaults toen— 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_VOICEenv 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
/speakpreempting voicebox playback. Fixed with the narration queue; don't "fix" this client-side.