talkback · diff
v0.2.0 to v0.3.0
305 added, 47 removed. Audit A to A.
---
name: talkback
category: audio
- version: 0.2.0
+ version: 0.3.0
description: >-
Speak an explanation out loud while working in any project — tiered
text-to-speech with a pluggable backend (ElevenLabs by default and
quota-guarded, macOS `say` via `--fast` for free instant local speech, local
OmniVoice as an unlimited private tier). Markdown-aware, so code fences, URLs
and deep paths collapse to short spoken placeholders instead of being dictated
character by character, while snake_case identifiers survive intact so the
listener can still search for them. Every utterance is saved to disk for later
- replay. Includes an optional Stop hook that speaks a short readback when a
- turn ends — off by default, and when on it speaks only on turns the agent
- explicitly marked, so acknowledgements never become audio. Use when the user asks to hear something rather than
- read it — an explanation of a change, a walkthrough of what just happened, a
- summary they want while looking away from the screen.
+ replay. Also carries a **talk mode** toggle: turn it on and the agent speaks a
+ full readback of every turn, for as long as that session lasts — the whole
+ response, not a summary of it, with `brief` and `marker` levels for when you
+ want less. Talk mode is off by default and scoped to the single session that
+ enabled it, so parallel agents in other worktrees stay silent. Use when the user asks to hear
+ something rather than read it — an explanation of a change, a walkthrough of
+ what just happened, a summary they want while looking away from the screen —
+ or when they want the session narrated as it goes.
author: broomva
license: MIT
tags: [tts, voice, audio, elevenlabs, accessibility, narration, explain, say]
trigger_keywords:
- talkback, talk back, /talkback
- explain this to me in voice, explain out loud, say that out loud
- read that to me, read this aloud, speak that, voice this
- tell me what you did, narrate that, walk me through it out loud
- i want to hear it, say it, out loud, audio explanation
- text to speech, tts, elevenlabs, voice quota
+ - talk mode, talk mode on, talk mode off, turn on talk mode
+ - keep talking, narrate everything, continuous talkback, talk to me
+ - stop talking, be quiet, mute, silence, stop the voice
when_to_use: >
The user wants to LISTEN rather than read — typically because they are away
from the screen, resting their eyes, or want a walkthrough while doing
something else. Default to the ElevenLabs voice — the plan affords it. Use
`--fast` for throwaway lines or when network latency matters. Speak a
written-for-the-ear summary, never a read-aloud of raw markdown; the listener
cannot scroll back, so lead with the conclusion.
---
# talkback — hear it instead of reading it
Speaks text aloud from any project directory, saves the audio, and never
silently spends a metered quota.
- **This is an on-demand tool.** It speaks when asked — the user says "explain that
- out loud", or the script is run directly. It does not narrate on its own. The
- turn-end hook at the bottom is an optional extra that ships off; ignore it unless
- the user asks for automatic readbacks.
+ **On demand by default.** It speaks when asked — the user says "explain that out
+ loud", or the script is run directly. It does not narrate on its own until
+ someone turns **talk mode** on, and talk mode belongs to one session at a time.
## Use it
```bash
S=~/.claude/skills/talkback/scripts
$S/talkback.py "Here is what changed and why it matters." # ElevenLabs (default)
$S/talkback.py --fast "Throwaway line." # local, instant, free
$S/talkback.py --quota # what's left
$S/talkback.py --voices # list voices
$S/talkback.py --dry-run "..." # see spoken text, synthesise nothing
+ $S/talkback.py --help # every flag
+
+ $S/talkback-hook.py --on # talk mode ON, this session only
+ $S/talkback-hook.py --off # stop talking
+ $S/talkback-hook.py --status # who is talking, is the hook wired
+ $S/talkback-hook.py --outputs # audio outputs on this host
```
Text can also be piped: `git log -1 --format=%B | $S/talkback.py`.
## How the agent should use it
+ Two different things live here. **Speaking once** is composed prose you pass to
+ `talkback.py`. **Talk mode** is a standing setting you flip with
+ `talkback-hook.py`; you never compose its text, the hook reads the turn.
+
+ ### Speaking once
+
When the user asks to hear an explanation, **write for the ear, then speak it.**
Do not pipe raw markdown or a diff into the tool. Compose two to five sentences
of plain spoken prose — what changed, why, what it means for them — and pass
that. The listener cannot scroll back, so lead with the conclusion.
Default to the good voice — the plan comfortably affords it. Reach for `--fast`
when the text is throwaway or you want zero network latency. State which backend
was used if it fell back.
+ ### Driving talk mode
+
+ What the user says maps to one command. Run it; do not also narrate the change
+ by hand, because the hook will speak the turn you are writing.
+
+ | The user says | Run |
+ |---|---|
+ | "talk mode on", "keep talking", "narrate this session" | `talkback-hook.py --on` |
+ | "stop talking", "mute", "be quiet" | `talkback-hook.py --off` |
+ | "just the highlights", "less detail" | `talkback-hook.py --on brief` |
+ | "only tell me the important bits" | `talkback-hook.py --on marker` |
+ | "use my AirPods", "play it on X" | `talkback-hook.py --outputs`, then `--on --output "<name>"` |
+ | "use the cheap voice", "stop spending quota" | `talkback-hook.py --on --backend say` |
+ | "is it on?", "why can't I hear anything?" | `talkback-hook.py --status` |
+ | "is something else talking?" | `talkback-hook.py --sessions` |
+ | "make everything quiet" (all sessions) | `talkback-hook.py --off --all` |
+
+ Three things to check before telling the user it works:
+
+ - **`--status` reports whether the hook is registered.** If it is not, run
+ `--install` and tell them a restart is needed.
+ - **If `--status` warns that the hook was registered after this session
+ started**, talk mode is on and will make no sound here. Say so — do not let
+ them discover it as silence.
+ - **Talk mode does not survive the session.** After a restart or a `/clear` it
+ is off again and needs `--on`. That is deliberate, not a bug; say it once
+ rather than letting them re-ask.
+
+ In `marker` mode — and any time you want the readback to be a written-for-the-ear
+ summary rather than the turn itself — end the message with a marker:
+
+ ```html
+ <!-- talkback: Refactored the auth layer, three call sites, tests green. -->
+ ```
+
## Backends
| Backend | Cost | Quality | Notes |
|---|---|---|---|
| `elevenlabs` (**default**) | metered | best | quota-guarded, auto-falls back to `say` |
| `say` (`--fast`) | free, unlimited | fair | macOS native, ~instant, no network |
| `omnivoice` | free, unlimited | good | local + private; needs the backend up. **Unverified** — see below |
+ ### The ladder
+
+ `elevenlabs → omnivoice → say`, best first. A rung that cannot take the job —
+ no key, quota spent to the reserve, local server down, synthesis error — hands
+ off to the next one down, so the voice degrades instead of the audio going
+ missing. `TALKBACK_CHAIN` reorders it.
+
+ Asking for a rung explicitly starts the ladder **there and only descends**:
+ `--fast` means "local now" and never climbs back up to a metered backend. Every
+ fallback prints the reason on stderr and the chosen backend lands in the ledger,
+ so a degraded run is never silent about being degraded.
+
`--strict` turns any fallback into a hard failure (exit 1) instead, for scripts
that must not silently degrade.
### Quota
The account is Creator tier: **130,958 characters/month**. A two-minute spoken
explanation is roughly 1,500 characters, so that is about 87 of them a month —
enough that the good voice can be the default rather than a treat.
Verify at point of use, never from memory — `talkback.py --quota` reads it live.
The tier has changed once already, and a number in a doc is stale the moment the
plan moves.
Before synthesising, the tool reads the live quota and keeps a 250-character
reserve, so one long explanation can never drain the balance completely. If the
request would not fit, it warns on stderr and uses `say` instead.
Credentials resolve in order: `$ELEVENLABS_API_KEY` → `~/.elevenlabs/api_key`
(written by `elevenlabs auth login`) → `ELEVENLABS_API_KEY` in
`~/broomva/.env.local`. Two distinct keys exist on this machine and they resolve
to the **same** account, so checking one is checking both.
Creator tier also unlocks **instant and professional voice cloning** (30 voice
slots, 1 professional). `--voices` lists what the account can currently use.
> The `@elevenlabs/cli` package is **not** used at runtime and cannot do this —
> its whole surface is `auth · agents · tools · tests · components`, which
> manages hosted ConvAI agent projects. It has no synthesis command. The CLI is
> useful here only for `auth login`, which writes the key file.
### OmniVoice tier is unverified
The `omnivoice` backend is implemented against the documented shape but was
**never exercised** — the local backend was down when this shipped. It degrades
cleanly (falls back to `say`, or fails under `--strict`). To bring it up, see
the `omnivoice` skill; the repo is already at `~/broomva/external/OmniVoice-Studio`.
## Spoken-text handling
Agent prose is not written to be heard, so the text is prepared first:
- code fences → `(code omitted)`; URLs → `(link)`
- headings and bullets become sentence breaks, so lists do not run together
- deep paths shorten to the basename (`src/lib/engine.py` → `engine.py`); pass
`--keep-paths` to hear them in full
- markdown emphasis is stripped **only where it delimits a span** — a bare
- underscore inside `resolve_backend` is left alone, because eating it turns a
+ underscore inside `backend_chain` is left alone, because eating it turns a
symbol the listener could search for into one they cannot
## Saved audio
Every utterance lands in `~/.talkback/audio/` as mp3 (converted via ffmpeg when
present) with a timestamped, slugged filename, and is appended to
`~/.talkback/ledger.jsonl`. Use `--no-save` to discard, `--out-dir` to redirect.
- ## Optional: speak a readback when a turn ends
+ ## Talk mode — narrate the session as it goes
- The `Stop` hook fires **every time the agent finishes a turn** — including
- one-line answers. Narrating all of those is worse than narrating none, so the
- hook is off by default and, when on, speaks only on turns that earn it.
+ Talk mode makes the agent speak a short readback at the end of **every turn**,
+ for as long as the session lasts. It ships **off**, it is turned on by hand, and
+ it belongs to **one session**.
```bash
- $S/talkback-hook.py --on # enable, marker mode (default)
- $S/talkback-hook.py --on always # enable, every substantial turn
- $S/talkback-hook.py --off # kill switch
- $S/talkback-hook.py --status # shows mode + backend
+ $S/talkback-hook.py --on # full detail, every turn, THIS session
+ $S/talkback-hook.py --on brief # opening lines only
+ $S/talkback-hook.py --on marker # only turns carrying a marker
+ $S/talkback-hook.py --on --backend elevenlabs # the good voice, metered
+ $S/talkback-hook.py --on --output "AirPods Pro" # which speaker this session uses
+ $S/talkback-hook.py --off # stop talking (this session)
+ $S/talkback-hook.py --off --all # kill switch, everywhere
+ $S/talkback-hook.py --status # mode, backend, who else is talking
+ $S/talkback-hook.py --sessions # every session currently talking
```
- ### Modes
+ ### Why session-scoped
- | Mode | Speaks when |
+ The `Stop` hook is registered once, globally and permanently — a toggle you
+ cannot flip during a session is not a toggle. So the hook fires in *every*
+ session, and the **flag** is what scopes it: talk mode is a property of a
+ session, not of the machine.
+
+ The flag is `~/.talkback/sessions/<session-id>`; a session that never opted in
+ has none, so the hook exits 0 without a sound. That is what keeps six parallel
+ agents in six worktrees silent while the one session you are watching talks. The
+ pre-0.3.0 design had a single machine-wide flag, which is why it was never
+ turned on: enabling it made every agent on the box audible at once.
+
+ The session id is read from `CLAUDE_CODE_SESSION_ID` or `CLAUDE_SESSION_ID`;
+ `--session <id>` sets it explicitly. When none of them answers, the command
+ prints `pass --session <id>` and exits 2 rather than guess — it used to fall
+ back to the newest transcript in the working directory, which meant that with
+ two sessions open in one directory `--on` enabled whichever session had been
+ touched most recently, not the one that asked.
+
+ On the hook side the payload's `session_id` is **authoritative**: when it is
+ present it is the only key the gate authorizes under. The transcript filename
+ stem is used only when that field is absent. Both were once accepted together,
+ which let a payload naming session A and session B's transcript resolve B's
+ opt-in and speak in A.
+
+ **`--session <id>` targeting another session is deliberate, and it is not a
+ security boundary.** Session A running `talkback-hook.py --session B --off`
+ enables or disables B's talk mode without B's consent — there is no ownership
+ check binding a flag to the session that set it. That is a feature, kept on
+ purpose rather than reverted: every session on the box runs as the same OS
+ user, so any session can already write another session's flag file directly
+ under `~/.talkback/sessions/`. An ownership check inside the CLI would only
+ stop the polite path through the flag, not the direct one — a same-principal
+ boundary cannot stop a writer who shares the principal. The flag scopes
+ *audio*, so parallel agents stay silent by default; it does not scope
+ *control*, and nothing here should be read as though it did.
+
+ ### Detail levels
+
+ | Mode | Speaks |
|---|---|
- | `marker` (**default**) | the agent left a `<!-- talkback: … -->` marker — and only then |
- | `always` | every turn over `TALKBACK_HOOK_MIN_CHARS` (80), marker preferred when present |
+ | `full` (**session default**) | the **whole** turn, every turn — led by the marker when the agent wrote one |
+ | `brief` | the opening `TALKBACK_HOOK_CHARS` (320) of turns over `TALKBACK_HOOK_MIN_CHARS` (80); a marker wins when present |
+ | `marker` | only turns carrying a `<!-- talkback: … -->` marker, and only the marker text |
+ | `off` | never — written by `--off` when a global flag would otherwise re-enable the session |
- `marker` is the default because the marker is **authored, not extracted**: the
- agent writes it deliberately when a turn did something worth hearing, so the
- hook stays silent through acknowledgements and speaks a real summary rather than
- whatever preamble the message happened to open with.
+ `full` is the default because the point of a readback is to walk away and come
+ back **knowing what happened**. A capped excerpt is a preview of the answer, not
+ the answer — you would still have to read the screen, which is the thing talk
+ mode exists to avoid. What gets dropped is only what cannot be heard at all
+ (code fences, URLs, deep paths — see *Spoken-text handling*), never what is
+ merely long. `full` has no length floor either: "done, tests green" is a result
+ you want when you are away, not noise.
- The mode is stored as the body of the flag file, so it survives restarts. An
- empty flag file (the pre-0.2.0 format) reads as `marker`.
- `TALKBACK_HOOK_MODE` overrides.
+ `brief` and `marker` are for a long unattended arc where you want the shape of
+ progress rather than the transcript of it.
+ `TALKBACK_FULL_MAX_CHARS` puts a ceiling on `full`, off by default — a ceiling
+ turns `full` back into `brief` at exactly the turns worth hearing in full.
+
+ Readbacks take the **whole backend ladder** — ElevenLabs first, descending only
+ when a rung is unusable. Full detail on every turn therefore spends real quota:
+ a 1,500-character turn is ~1% of the monthly Creator balance, so a long session
+ will reach the reserve, at which point it keeps talking on the next rung rather
+ than going quiet. `--on --backend say` pins it low, `--on brief` shortens it.
+
+ ### When one readback runs into the next
+
+ A full readback can still be playing when the next turn ends.
+ `TALKBACK_ON_OVERLAP` decides what happens:
+
+ | Value | Behaviour |
+ |---|---|
+ | `interrupt` (**default**) | the new readback cuts off the old one — the newest state is the one worth hearing |
+ | `queue` | they play in order, so a long detailed readback is never truncated; you fall behind but hear everything |
+
+ `queue` is the right setting for an unattended arc you intend to listen back to
+ in full; `interrupt` is right when you are at the machine and want the latest.
+
### The marker
- To opt a turn in, end the message with:
+ To opt a single turn in — in `marker` mode, or to speak a written-for-the-ear
+ summary instead of the message's opening lines — end the message with:
```html
<!-- talkback: Refactored the auth layer, three call sites, tests green. -->
```
- In `always` mode a markerless turn falls back to the opening sentences of the
- message, capped at 320 characters (`TALKBACK_HOOK_CHARS`) and trimmed to a
- sentence boundary.
+ In `full` mode the marker becomes the **headline**: it is spoken first, then the
+ whole turn behind it, so the readback leads with the conclusion without losing
+ the detail. In `brief` mode the marker *replaces* the excerpt, and a markerless
+ turn falls back to the opening sentences trimmed to a sentence boundary.
+ ### Which speaker
+
+ Audio comes out of the **machine running Claude Code**. A session you are
+ driving from a phone, a tablet or another laptop still sounds on the host, so
+ the lever that matters is choosing which of the *host's* outputs it lands on —
+ AirPods paired to the Mac, an AirPlay speaker, a display, the built-in speakers.
+
+ ```bash
+ $S/talkback-hook.py --outputs # what this host can play through
+ $S/talkback-hook.py --on --output "AirPods Pro"
+ $S/talkback.py -d "MacBook Pro Speakers" "one-off line on a chosen device"
+ ```
+
+ The output is stored **per session**, so two sessions on one host can come out
+ of two different speakers. `TALKBACK_OUTPUT` overrides. An unset output means
+ the system default.
+
+ A device is named the way `say -a '?'` names it. Routing to a named device goes
+ through ffmpeg's `audiotoolbox` muxer, because `afplay` cannot target one; if
+ ffmpeg is missing or the name does not resolve, it warns on stderr and plays on
+ the default device rather than failing the readback.
+
+ ### Lifecycle
+
+ - `SessionEnd` deletes the session's flag, so talk mode never outlives the
+ session that asked for it. `/clear` ends a session too — re-enable after one.
+ - Sessions that die without a `SessionEnd` (a killed terminal, a crashed
+ harness) are reaped by an idle TTL, `TALKBACK_SESSION_TTL_HOURS` (24). Every
+ spoken turn touches the flag, so this is an idle timeout and not a cap on how
+ long a session may talk.
+ - Overlap is governed by `TALKBACK_ON_OVERLAP` (above). `TALKBACK_BARGE_IN=0`
+ disables the interrupt without switching to a queue, letting readbacks overlap.
+
+ ### The global flag, if you really want it
+
+ ```bash
+ $S/talkback-hook.py --on --global # every session on the machine speaks
+ ```
+
+ Deliberately a separate gesture, and it defaults to `marker`. A session can
+ still mute itself over it (`--off` writes an `off` flag rather than deleting
+ one, so the session does not fall back to the global setting), and
+ `--off --all` clears everything.
+
### Safety properties
- - **Off unless the flag file exists** — an unconfigured machine is silent.
+ - **Silent unless a flag exists** — an unconfigured machine, and every session
+ that did not opt in, make no sound.
- **Always exits 0**, so it can never block a turn from completing. It survives
- malformed stdin, `{}`, and a missing transcript.
- - **Detaches playback**, so no turn waits on audio (measured at 0s).
+ malformed stdin, `{}`, a missing transcript, and a backend that throws.
+ - **No identity, no audio** — a payload carrying neither a session id nor a
+ transcript path is not spoken for, because isolation could not be guaranteed.
+ - **Session keys are validated** before becoming a path, so a `../` in a payload
+ cannot point the flag lookup outside `~/.talkback/sessions/`.
+ - **Detaches playback**, so no turn waits on audio.
- **Defaults to the free backend** even when a metered one is affordable: it
- fires unattended, for audio nobody asked for. `TALKBACK_HOOK_BACKEND` overrides.
+ fires unattended, on every turn, for audio nobody asked for.
+ ### Full CLI
+
+ `talkback-hook.py --help` prints this; it is repeated here so an agent that has
+ loaded the skill never has to shell out to discover a flag.
+
+ | Command | Does |
+ |---|---|
+ | `--on [full\|brief\|marker]` | talk mode ON for this session, at that detail level (default `full`) |
+ | `--off` | stop talking in this session |
+ | `--status` | mode, backend, output, other talking sessions, registration state |
+ | `--sessions` | every session currently talking |
+ | `--outputs` | audio output devices on this host |
+ | `--install` | register the `Stop` + `SessionEnd` hooks in `~/.claude/settings.json` |
+ | `--uninstall` | remove them again |
+ | `-h`, `--help` | usage |
+
+ | Option | Applies to | Does |
+ |---|---|---|
+ | `--global` | `--on`, `--off` | act on the machine-wide flag — **every** session speaks |
+ | `--all` | `--off` | kill switch: clear every session flag and the global one |
+ | `--backend <name>` | `--on` | `elevenlabs` \| `omnivoice` \| `say` — the top rung for this session |
+ | `--output <device>` | `--on` | output device, by name or `say -a` id |
+ | `--session <id>` | any | target another session instead of the current one |
+ | `--quiet` | `--on` | skip the spoken "talk mode on" confirmation |
+ | `--dry-run` | `--install` | print what would change, write nothing |
+
+ With no arguments the script **is** the hook: it reads a payload on stdin and
+ exits 0. Never run it bare by hand.
+
+ And the one-off speech CLI, `talkback.py`:
+
+ | Flag | Does |
+ |---|---|
+ | `-b`, `--backend <name>` | `elevenlabs` (default) \| `omnivoice` \| `say` — top of the ladder |
+ | `--fast` | force `say`: local, instant, free, and never climbs back up |
+ | `--good` | force `elevenlabs` (already the default) |
+ | `-v`, `--voice <v>` | a `say` voice name, or an ElevenLabs voice id |
+ | `--model <id>` | ElevenLabs model id (default `eleven_turbo_v2_5`) |
+ | `-d`, `--device <dev>` | audio output device |
+ | `--outputs` | list output devices and exit |
+ | `--voices` | list available voices and exit |
+ | `--quota` | report the live ElevenLabs balance and exit |
+ | `--dry-run` | print the spoken text and chosen backend, synthesise nothing |
+ | `--no-play` | synthesise and save, play nothing |
+ | `--no-save` | play, then discard the audio |
+ | `--out-dir <dir>` | where audio lands (default `~/.talkback/audio/`) |
+ | `--keep-paths` | read full file paths aloud instead of just the basename |
+ | `--strict` | any fallback becomes a hard failure (exit 1) |
+
### Registering it
+ ```bash
+ $S/talkback-hook.py --install --dry-run # show what would change
+ $S/talkback-hook.py --install # register Stop + SessionEnd
+ $S/talkback-hook.py --uninstall # remove them again
+ ```
+
+ `--install` edits `~/.claude/settings.json` idempotently, backing it up first,
+ and adds:
+
```json
{
"hooks": {
- "Stop": [
- { "hooks": [ { "type": "command",
- "command": "~/.claude/skills/talkback/scripts/talkback-hook.py" } ] }
- ]
+ "Stop": [ { "hooks": [ { "type": "command", "command": ".../talkback-hook.py" } ] } ],
+ "SessionEnd": [ { "hooks": [ { "type": "command", "command": ".../talkback-hook.py" } ] } ]
}
}
```
- Goes in `~/.claude/settings.json`. Registration alone does nothing until
- `--on`; both are required.
+ Claude Code reads hooks at session start, so a fresh install takes effect in the
+ **next** session — and `--on` in a session that started before the install will
+ sit there enabled and silent. That silence is indistinguishable from the feature
+ not working, so `--on` and `--status` detect the case (install timestamp versus
+ the session transcript's creation time) and say it outright:
+ ```
+ ⚠ this session started BEFORE the hook was registered, so the
+ harness never loaded it here — nothing will be spoken until you
+ restart Claude Code and run --on again in the new session.
+ ```
+
+ Registration alone makes no sound; `--on` is still required, and it is required
+ again in every session.
+
## Environment
| Variable | Default | Meaning |
|---|---|---|
| `TALKBACK_BACKEND` | `elevenlabs` | default backend |
| `TALKBACK_SAY_VOICE` | `Samantha` | macOS voice name |
| `TALKBACK_ELEVEN_VOICE` | River | ElevenLabs voice id |
- | `TALKBACK_HOOK_CHARS` | `320` | readback cap |
- | `TALKBACK_HOOK_BACKEND` | `say` | backend for the Stop-hook readback |
- | `TALKBACK_HOOK_MODE` | `marker` | `marker` or `always` |
+ | `TALKBACK_HOOK_CHARS` | `320` | `brief` excerpt cap |
+ | `TALKBACK_FULL_MAX_CHARS` | `0` | ceiling on `full` (0 = none) |
+ | `TALKBACK_ON_OVERLAP` | `interrupt` | `interrupt` or `queue` when readbacks collide |
+ | `TALKBACK_CHAIN` | `elevenlabs,omnivoice,say` | the quality ladder, best first |
+ | `TALKBACK_HOOK_BACKEND` | `elevenlabs` | top rung for talk-mode readbacks |
+ | `TALKBACK_OUTPUT` | *(unset)* | audio output device, overriding the session's |
+ | `TALKBACK_HOOK_MODE` | *(unset)* | overrides the stored mode: `full`, `brief` or `marker` |
| `TALKBACK_HOOK_MIN_CHARS` | `80` | floor below which `always` mode stays silent |
+ | `TALKBACK_SESSION_TTL_HOURS` | `24` | idle timeout that reaps a dead session's flag |
+ | `TALKBACK_BARGE_IN` | `1` | a new readback cuts off one still playing |
| `TALKBACK_HOME` | `~/.talkback` | state + audio directory |
| `OMNIVOICE_API_URL` | `http://localhost:3900` | local backend |