Developer guide

PocketPal

A tiny always-on-top macOS desktop widget: Clippy's ears are always on, Claude is his brain, and his voice and hearing run entirely on two CPU cores. This guide is the map: how the pieces fit, where to make a change, and what not to break.

Python 3.10–3.13 pywebview + uv CPU-only, no GPU macOS MIT

Quick start

See it running before reading a word about how it works.

# no clone needed
uvx git+https://github.com/ameroyer/pocketpal

# or, from a clone of the repo
uv run python -m pocketpal

…or just double-click PocketPal.command in Finder (first time: right-click → Open, since it isn't signed; it installs uv for you if needed). First run downloads the models; Clippy shows up on your desktop a minute or so later, already listening. Just talk to him; nothing to configure first.

Checking a change actually works

The widget picks up edits only on relaunch. For a headless check instead of a full restart, uv run python -m pocketpal --selftest exercises the real voice pipeline end to end (see §9), just not while a live instance is running; the two compete for the mic and the TTS model.

§1 What this is

Read this first: the one-paragraph mental model everything else builds on.

PocketPal is a single small window that floats on top of your desktop: a mascot (Clippy, by default) that listens continuously, transcribes what you say on-device, and answers out loud. Speech in and speech out are both local models running on CPU: Kyutai's pocket-tts for the voice and Moonshine for the ears, with Silero VAD doing speech detection. The brain behind the mascot is always Claude, through the user's claude CLI (no API key needed) or, as a fallback, an OpenRouter model.

The interesting engineering is not the TTS/ASR models themselves (they're upstream libraries); it's the plumbing around them: an always-on listener that has to tell "background noise" from "the user is talking," barge-in detection that has to ignore the mascot's own voice leaking back through the microphone, and a brain dispatcher that can hand control to a live terminal session (typing into someone's real tmux pane, local or over SSH) as easily as it calls a hosted model.

Repo shape

A small Python package, pocketpal/ (one module per concern), plus two embedded pages: pocketpal/ui.html is the entire main UI (SVG mascots, toolbar, chat bar, settings panel), and pocketpal/history.html is the small conversation-history window. There is no separate frontend build step for either.

§2 Architecture at a glance

Nine modules, each one concern. Nothing reaches past its neighbor's boundary.

graph TD
  UI["ui.html: SVG mascots + JS bridge"] <--> API["api.py: Api, the JS↔Python bridge"]
  API --> ENGINE["engine.py: job queue, TTS, always-on ears"]
  API --> SESSIONS["sessions.py: discover / drive live sessions"]
  API --> HIST["history.html: 📜 conversation window"]
  ENGINE --> AUDIO["audio.py: Vad + Player"]
  ENGINE --> BRAINS["brains.py: claude CLI / OpenRouter streaming"]
  ENGINE --> SESSIONS
  ENGINE --> TEXT["text.py: sentence split + speech filter"]
  ENGINE --> PERSONAS["personas.py: persona_for()"]
  ENGINE --> CONFIG["config.py: load / save / update_config"]
  BRAINS --> CONFIG
  PERSONAS --> CONFIG
  APP["app.py: run_app()"] --> API
  APP --> UI
  MAIN["__main__.py"] --> APP
  MAIN --> SELFTEST["selftest.py: headless end-to-end test"]
  SELFTEST --> ENGINE

Two rules keep this graph from turning into a hairball: engine.py owns all state that changes during a conversation (the job queue, the ears thread, per-mascot session ids), and config.py is the only place allowed to touch config.json: every other module reads it through load_config() and writes through update_config(), never around it.

§3 How a voice turn flows

From "you start talking" to "he's talking back", and how he yields the floor again.

graph LR
  MIC[Microphone] --> VAD["Silero VAD
32 ms frames"] VAD --> END["Endpoint on pause
('patience' knob)"] END --> ASR["Moonshine ASR
(or asr_cmd)"] ASR --> CHAT["Engine.chat()"] CHAT --> PICK{model prefix} PICK -->|cli:*| CLI["claude -p
read-only eyes"] PICK -->|session:*| DRIVE["type into local tmux pane"] PICK -->|rsession:*| SSH["ssh + tmux, same dance"] PICK -->|or:*| OR["OpenRouter chat completion"] CLI --> STREAM["sentence-by-sentence TTS"] DRIVE --> STREAM SSH --> STREAM OR --> STREAM STREAM --> SPK[Speakers] SPK -. echo .-> MIC

The dotted line back into the microphone is the crux of the whole ears design: while the mascot is speaking, its own voice is audible in the mic. Engine._ears_loop calibrates, in the first 0.7 seconds of real playback of every reply, how loud that echo is relative to what's coming out of the speakers (couple). That ratio is then frozen for the rest of the reply: only sustained speech clearly louder than the predicted echo counts as a barge-in (the interrupt knob controls how long). Get this calibration wrong and either the mascot can never be interrupted, or it interrupts itself.

Text comes back from the brain as a stream of deltas. Engine._voice_stream buffers those into sentences (text.split_stream_buffer) and speaks each one as soon as it's complete; it never waits for the whole reply. A CodeFenceStripper drops fenced code blocks before they reach the speech filter, and is_speakable() rejects anything that looks like a path, a table row, or a rule rather than a spoken sentence. When driving a live terminal session, text can arrive faster than it can be spoken; _next_spoken keeps strict FIFO order normally, but once the oldest queued sentence is stale and a fresher one has landed since, it drops the backlog and jumps to the newest. A batch that all arrived at once (one long reply) is always read in full; there's nothing fresher to catch up to.

§4 The three brains

Everything the mascot can do (as opposed to just say) depends entirely on which brain answers. This is also the security boundary; see §8.

🌐
or:*

OpenRouter model

Plain chat completion over HTTPS. No tools at all: NO_TOOLS_STYLE is appended to the persona. Needs an API key. History is a capped in-memory list (MAX_HISTORY = 16).

cli:*

Fresh claude CLI model

A headless claude -p call through the user's own Pro/Max subscription, one session per mascot (--resume). Gets read-only eyes (Read, Grep, Glob) anywhere on disk, never write or shell tools.

💻 / 🌐
session:* · rsession:*

A live terminal session

Attaches to a claude the user already has running in a terminal, local or over SSH. Full hands (Bash, Edit, Write) inside that session's own project folder. The widget is a voice and remote control, not a separate brain.

The model string's prefix is the entire dispatch key: Engine._chat_job switches on it once, at the top. A live session then splits again on how it's reached:

Reachable howMechanismMemory / continuity
🖥 full remote
(session in local tmux)
Your words are literally typed into the pane (tmux send-keys); the reply is spoken by tailing that session's own transcript as it grows. The terminal session is the memory; the widget keeps none of its own.
👓 ride-along
(no tmux reachable)
Each turn is a throwaway --fork-session probe: full sight of the session's history, real hands in its project folder, transcript untouched. Fork is deleted after each turn (_forget_forked_session); voice continuity rides in a short recap kept per mascot.
🛰 SSH
rsession:<host>:<pid>
Same full-remote dance, executed on a listed host over one streaming ssh call with fixed shell templates (_SSH_DISCOVER / _SSH_DRIVE). The remote session is the memory; ControlMaster reuses one connection per host.
The one lever a driven session has

🖥 full remote and 🛰 SSH get no persona at all. The reply is that session's own words, verbatim, so there's no system prompt to ask for brevity. Instead, engine._with_drive_hint() appends one fixed line to the literal text typed into the pane. It's not a vague "keep it short" but a concrete ban on the habits that actually make replies long: bullet lists, headers, code blocks, a step-by-step recap. The line itself is config.DEFAULT_DRIVE_HINT (§6); the ⚙ panel's hint row can override or reset it. Every other brain tier already gets brevity from SPOKEN_STYLE (§7) and never sees this at all.

§5 File-by-file tour

What lives where, and the one or two things worth knowing before you touch it.

config.py~100 lines

Paths, constants, and the config store. load_config() fills in defaults and migrates stale values (e.g. a session: brain pointing at a terminal that's gone). update_config(mutate) is the only sanctioned write path: it takes a RLock, read-modifies-writes under it, and save_config() writes temp-file-then-os.replace so a crash mid-write can never truncate the real file. JS-API calls land on separate pywebview worker threads; skipping this lock is how two settings changes clobber each other. Also holds DEFAULT_DRIVE_HINT (§4).

APP_DIRCONFIG_JSONPRESET_VOICESDEFAULT_DRIVE_HINT
text.py~85 lines

Turning model text into speakable sentences. scrub_for_tts strips markdown/emoji/tag noise (and un-spells URLs: a markdown link keeps its label, a bare one becomes "a link," never read out letter by letter); is_speakable rejects things that look like code, paths, table rows, or rules (checked on the raw pre-scrub text, since scrubbing erases the structure that gives it away); CodeFenceStripper is a streaming filter that drops fenced code blocks split across arbitrary chunk boundaries.

scrub_for_ttsis_speakableCodeFenceStrippersplit_stream_buffer
personas.py~130 lines

The character half of every system prompt. Five mascots live in the PERSONAS dict (clippy, pocket, tama, moshi, imp), each with a default voice in DEFAULT_VOICES (see §7). persona_for(mascot, hands_dir, eyes) composes the (possibly user-edited) character with SPOKEN_STYLE (always: "you're really Claude, write like speech, keep it short") and exactly one capability clause: NO_TOOLS_STYLE, READONLY_STYLE, or HANDS_STYLE.

PERSONASDEFAULT_VOICESpersona_for()
brains.py~290 lines

Which Claude answers. find_claude_cli() searches common install paths (a Finder-launched app has a bare PATH). stream_claude_cli runs claude -p --output-format stream-json and yields text deltas, with a stall guard: a call that produces zero output for 60s is killed and redialed once (300s stall on the retry); the API occasionally goes dead for minutes, and that's not the same as an error. stream_chat is the OpenRouter SSE path.

stream_claude_cli_CliStallCLI_BRAINSOR_BRAINS
sessions.py~540 lines

Attaching to a running claude that isn't the widget's own. live_claude_sessions() reads the CLI's own pid registry (~/.claude/sessions/*.json); _tmux_pane_for_pid walks process ancestry to find a reachable tmux pane. drive_live_session / drive_ssh_session type into that pane and tail the session's transcript (~/.claude/projects/<encoded-cwd>/<session_id>.jsonl) to speak the reply. The SSH half only ever runs two fixed shell templates on the remote host; the model never composes an SSH command.

live_claude_sessionsdrive_live_sessiondiscover_ssh_sessionsvalid_ssh_host
audio.py~165 lines

Vad wraps the Silero VAD ONNX model (auto-downloaded, ~2.3 MB): feed 512-sample 16 kHz frames, get back P(speech); falls back to an energy threshold if it can't load. Player streams float32 chunks to the speakers via a lock-guarded queue and exposes a live RMS level, which is exactly what the barge-in echo calibration in engine.py reads.

Vad.prob()Player.push() / .level
engine.py~875 lines, the core

Owns the model, a job queue, playback, and the always-on ears. _chat_job is the dispatcher described in §4. _voice_stream turns a text-delta generator into speech sentence-by-sentence. _ears_loop is the always-on listener: adaptive noise floor (clamped: an unclamped floor is the bug that once made the ears go permanently deaf), VAD-or-energy speech detection, endpointing on the patience knob, and the barge-in / echo-rejection logic from §3. Also home to voice cloning (record_voice), the bring-your-own-ASR hook (_transcribe_cmd), and _with_drive_hint, the one piece of text in this whole file that's deliberately not mascot-specific, unlike the persona and voice defaults (§4).

Engine._chat_jobEngine._voice_streamEngine._ears_loop_next_spoken_with_drive_hint
api.py~325 lines

The JS-facing surface (pywebview's js_api) and the reverse channel: _emit pushes engine events into the page by evaluating handlePy(event) in JS. Every method here is one user action: chat, set_model, set_persona, toggle_ears, set_tuning, record, list_sessions. Thin by design: it validates input and hands off to Engine or config.

A few methods stand out. speak(text) reads a fixed string aloud verbatim via Engine.speak() (no persona, no brain), used for the ⚙ panel's ssh "scan" button announcing what it found. set_drive_hint and reset_drive_hint are the pair behind the hint row: reset removes the override key entirely, which is a different, permanent "disabled" state than just saving an empty string (§6). open_history, sync_history, and close_history host the 📜 window: a second real pywebview window next to the main one, not an overlay. JS still owns the actual history list; these three just relay it and manage the window.

Api.init()Api.speak()Api.open_history()Api.reset_drive_hint()
app.py~90 lines

Creates the frameless, always-on-top, transparent pywebview window and loads ui.html as an inline string. Installs a SIGINT handler so Ctrl-C runs the same clean stop-then-os._exit(0) path as the toolbar's quit button, instead of letting the default handler raise into native teardown mid-flight (the macOS crash-dialog bug that used to cause). Writes a pid file so a second launch can warn about two sets of ears fighting over one microphone. --smoke N support lives here too.

run_app()
selftest.py~665 lines

The headless end-to-end test; see §9 for what it covers and how to run it.

ui.html~1950 lines, the whole main frontend

One file: inline <style>, five inline SVG mascots (#pocket, #clippy, #tama, #moshi, #imp; a body CSS class picks which one shows), and a plain-JS controller with no framework and no build step. handlePy(e) is the single entry point for every event the Python side pushes (status changes, spoken sentences, mouth visemes, ASR results, tool/agent notifications); every user action goes back out through pywebview.api.*. All dynamic values are rendered via textContent or escaped (see §8). Owns the HISTORY array shown in the 📜 window (below), never persisted, cleared on a fresh start or mascot switch.

handlePy()resetChatDisplay()pickVerb()
history.html~80 lines, the 📜 window

A tiny standalone page for the conversation-history window: a bubble per side (you on the right, mascot on the left) and a copy all button. Both UI pages disable text selection globally by default, since the whole page would otherwise fight you for drag-to-move; this one opts back in on the message area only, and sets easy_drag=False on the window itself so click- and-drag there selects text instead of moving the window; only the header still drags it.

Its initial content is baked straight into a <script> tag at window-creation time, by Api.open_history; a literal </script> in someone's chat text is escaped first so it can't break out of that tag. Later updates arrive through renderHistory(entries), called via evaluate_js instead.

renderHistory()

§6 Config & persistence

One JSON file, one lock, one write path.

Everything persists to ~/.cache/pocket_tts_widget/config.json: voice, mascot, current brain, ear-tuning knobs, persona overrides, the OpenRouter key (plaintext, see §8), and the SSH host list. The same directory holds the recorded-voice files, the widget's pid file, and a capped pastes/ folder for screenshots pasted into the chat bar.

KeyDefaultNotes
voice"alba"a pocket-tts preset, or __my_voice__ after cloning
mascot"clippy"selects the persona + which SVG shows
modeldefault_brain()first available CLI brain, else first OpenRouter one
earstruealways-on mic, persisted mute state
vad_sens500–100, "hearing" knob
patience_ms800pause length that ends a turn
interrupt_ms450talk-over duration that barges in; 0 disables
personas{}per-mascot system-prompt overrides
ssh_hostsunsetcomma-separated, validated by valid_ssh_host
asr_cmdunsetbring-your-own transcription shell command, no UI
drive_hintsee config.DEFAULT_DRIVE_HINTthree states: key absent = built-in default, "" = disabled, any other string = fixed override
Rule, not a suggestion

Never call load_config() / save_config() back-to-back to change a value. Always go through update_config(mutate): the lock is what stops two concurrent JS-API calls (they run on separate pywebview worker threads) from a last-write-wins race that silently drops one of them.

"Reset" vs. "blank": not the same state

drive_hint is the one config value with a real three-way split: saving the ⚙ panel's hint box blank writes an explicit empty string: disabled until you change it. Hitting reset instead removes the key from config.json entirely, which reactivates the built-in default, and the box then shows that literal built-in text, so you can see exactly what's being sent and edit it from there.

§7 Personas & mascots

The character is a costume, never a cage, and that line is literally in every prompt.

persona_for() builds the final system prompt as three layers, always in this order:

  1. Character: the mascot's voice and personality (PERSONAS[mascot], or a user override from config["personas"] saved through the ⚙ panel's personality editor).
  2. Capability clause: exactly one of NO_TOOLS_STYLE, READONLY_STYLE, or HANDS_STYLE.format(dir=…), chosen by which brain is answering (§4). This is what actually gates what the model will attempt, not just what it's told: a CLI call without hands genuinely has no write/shell tools wired in.
  3. SPOKEN_STYLE, always appended last: write like speech, no markdown, no lists, keep it short, never read code or files aloud verbatim, and, importantly, drop the bit and do real work when the user actually needs it.
Editing personas at runtime

The ⚙ panel's personality editor writes straight to config["personas"][mascot] via Api.set_persona; an empty box or the stock text removes the override. Any persona change clears that mascot's CLI session and chat history: a new personality starts a fresh conversation by design, not by accident.

Each mascot's default voice

personas.py also holds DEFAULT_VOICES, a plain {"mascot": "voice"} dict, edited directly in source (not through the UI), that maps each mascot to one of the pocket-tts presets in config.PRESET_VOICES:

DEFAULT_VOICES = {
    "clippy": "charles",
    "pocket": "alba",
    "tama": "eve",
    "moshi": "george",
    "imp": "vera",
}

This is not the same thing as config["voice"]: that's the one persisted voice the engine is actually speaking with right now. DEFAULT_VOICES is only consulted at the moment you switch mascots: Api.set_mascot looks up the new mascot's entry and writes it into config["voice"] for you (and hands the value back so the ⚙ panel's voice dropdown updates to match). Pick a different voice from that dropdown afterward and it sticks (same as any other persona switch) until you switch mascots again, at which point the new mascot's default takes over. Add a mascot without an entry here and it simply keeps whatever voice was already selected.

Voices are mascot-flavored, the terminal hint deliberately isn't

DEFAULT_VOICES and PERSONAS are keyed by mascot because they're about character. DEFAULT_DRIVE_HINT (§4, §6) is a single flat string with no mascot in its lookup: it's a real instruction typed as user input to a live Claude session, not a line of dialogue, so it stays plain and the same for everyone. Don't reintroduce a per-mascot dict there; it's been tried (§10).

§8 Security model

The honest framing, straight from SECURITY.md: who gets to drive Claude, and what Claude can then touch.

The core trade-off

The microphone is an unauthenticated command surface. Speech is detected, transcribed, and sent to whichever brain is selected: no wake word, no speaker verification, no confirm-before-act. Anyone your mic can hear can talk to the mascot. What that buys them depends entirely on the brain tier (§4): a fresh model can read any file on disk and speak the contents aloud (an exfiltration channel if pointed at something untrusted); a live-session brain can write files and run shell commands, because those tools are pre-approved for that headless call.

Mitigations that already exist in code, worth knowing before you remove or "simplify" any of them:

  • SSH host validation: valid_ssh_host rejects any host token starting with -, since ssh has no -- end-of-options and would otherwise parse -oProxyCommand=… as a local option, a local-RCE path closed at both write and read of ssh_hosts.
  • Fixed remote actions only: the SSH driver runs two hardcoded shell templates; the model never composes an SSH command, and the one user-controlled string (the transcribed message) crosses as a single control-char-scrubbed, shell-quoted argument.
  • Path-traversal guard on fork cleanup: _forget_forked_session validates the fork id as a bare UUID and checks the resolved path stays inside the project directory before any unlink/rmtree.
  • Bounded, collision-free paste directory: pasted screenshots get unique names and the directory is capped at 20 files.
  • Atomic, locked config writes: see §6.
  • Escaped script-embedding in the history window: Api.open_history escapes a literal </script> in chat text before baking it into the initial HTML, so a reply can't break out of its own script tag (§5).

The webview layer has been audited end to end: every dynamic value rendered into the page is HTML-escaped or set via textContent, the JS transport uses json.dumps with ensure_ascii=True, and every subprocess call is argv-based (tmux send-keys -l --, pbcopy via stdin), except two intentional shell hooks: asr_cmd (config-controlled, by design: treat write access to config as code execution) and the underlying ssh … "bash -s" calls (fixed templates, no user text ever becomes shell syntax). Read the full write-up in SECURITY.md before changing anything touching sessions, SSH, or asr_cmd.

§9 Running & testing

One command to run it, one to prove the voice pipeline still works.

# run it (no clone needed)
uvx git+https://github.com/ameroyer/pocketpal

# or, from a clone of the repo
uv run python -m pocketpal

# or just double-click PocketPal.command in Finder
# (first time: right-click -> Open; it installs uv if needed)

The self-test

uv run python -m pocketpal --selftest is headless (muted, earless) but exercises the real pipeline end to end, against a temporary copy of the config so it never touches real settings:

CoversWhat it actually proves
TTS + visemesthe model synthesizes audio and the mouth-shape extraction sees real energy
Speaks-on-arrivala sentence is voiced the moment it appears in the stream, not held until the stream ends
ASR round-tripMoonshine transcribes the TTS's own output back correctly
Always-on earsthe full detect → endpoint → transcribe → emit pipeline, against a fake replayed mic
Barge-in + echo rejectiontalking over him interrupts him; his own speaker echo never does
Speech filtercode fences, lists, table rows, and URLs never reach the voice unfiltered
Voice-prompt mergea follow-up before the reply is audible extends the same prompt
drive_hint statesabsent key falls back to the built-in default; a custom override always wins verbatim; blank disables it; reset_drive_hint removes the key cleanly
Live chat, eyes, session brainonly if a claude CLI is present: a real chat turn, a read-only file read, and a fork-sidecar session turn with hands and cleanup verified
If you're running this on a machine with a live PocketPal already open

--selftest and --smoke spawn a second instance that loads the TTS model and warms Moonshine: real CPU load, and (outside smoke mode) a second always-on mic. Don't run the heavy suites mid-conversation with a live widget; consider nice -n 19 and batching runs.

Debug switches

Env varEffect
POCKET_WIDGET_EARS_DEBUG=1prints mic level vs. speech threshold (and barge-in progress) once a second
POCKET_WIDGET_DEBUG=1opens the webview inspector
POCKET_WIDGET_OPAQUE=1solid window fallback if transparency misbehaves
POCKET_WIDGET_STAY_CURRENT=0disable catch-up: read a driven session's narration in strict order, even if it falls behind
POCKET_WIDGET_NO_VAD=1force energy-only speech detection (skip Silero VAD)

§10 Sharp edges

Non-obvious constraints that look like bugs if you don't know they're intentional.

The adaptive noise floor is clamped, on purpose. _thresh_on / _thresh_keep bound the floor-derived threshold into a fixed range. An unclamped floor creeps up over minutes of ambient noise until real speech can no longer cross it, the bug that once made the ears go permanently deaf.

The speaker→mic echo coupling is calibrated once per reply, then frozen. Any live re-adaptation of that ratio ends up tracking the user's voice instead of the mascot's echo, and barge-in stops working (or fires on the mascot's own speech). See §3.

A stalled claude CLI call is not the same as a failed one. The API line occasionally goes fully silent for minutes; _CliStall is what tells "dead connection" apart from "genuinely erroring," and it's why a chat can appear to redial mid-conversation.

Selftest monkeypatches must target the owning module's attribute, e.g. pocketpal.sessions._tmux_pane_for_pid, not a local alias imported into selftest.py. Patching the wrong reference once made a test type a live prompt into a real terminal session instead of the fake one it thought it was driving.

drive_hint resisted being made mascot-specific, on purpose. It was tried (a rotating pool of in-character lines per mascot) and reverted: this text is typed as real user input into someone else's live Claude session, which has to parse it as an instruction, not a costume. One flat, boring, unmistakable line beats five charming ones here. See §7.

§11 Common edits

Where the wiring for each of these actually lives.

Add a new mascot

  1. Add a character entry to PERSONAS in personas.py: just the character voice, not the capability/spoken-style plumbing (persona_for() appends that).
  2. Optionally give it a default voice in DEFAULT_VOICES (same file), any id from config.PRESET_VOICES. Skip it and the mascot just keeps whatever voice was already selected when you switch to it.
  3. Add an inline SVG with a matching id in ui.html, and wire the mascot picker / body-class switch that decides which SVG is visible.
  4. Add it as an option to the mascot <select> in ui.html.
  5. Sanity-check with --selftest; it exercises persona composition and a live chat turn if a brain is available.

Change a mascot's default voice

Edit its entry in DEFAULT_VOICES in personas.py: no UI, no config migration, just the dict. It takes effect the next time that mascot is selected (via Api.set_mascot); it doesn't retroactively change the voice of a mascot you're already talking to.

Change the driven-session hint

Edit DEFAULT_DRIVE_HINT in config.py: one string, no mascot dimension (see the sharp edge in §10 before reintroducing one). Users can still override or disable it per-machine from the ⚙ panel without you touching source at all.

Add a new brain (a CLI alias or an OpenRouter model)

Append a (id, label) tuple to CLI_BRAINS or OR_BRAINS in brains.py. That's the whole change: the settings dropdown is populated purely from available_brains() via Api.init(), no UI wiring needed.

Add a new persisted config key

Add its default to the defaults dict in config.py's load_config(), and only ever mutate it through update_config(mutate); see the rule in §6. If the key needs a real three-state "unset vs. blank vs. custom" distinction like drive_hint, leave it out of defaults entirely instead and handle the fallback where it's consumed.

Change the ear-tuning defaults

vad_sens / patience_ms / interrupt_ms defaults live in config.py; the live clamps applied when the user drags a slider live in Api.set_tuning; keep the two ranges in sync if you touch either.

Resize the window

WIN_W, WIN_H_COMPACT, WIN_H_EXPANDED in config.py. Api.set_expanded clamps the expanded height to 760px regardless. The 📜 history window is sized separately, inline in Api.open_history; it isn't one of these three.

Add a debug env var

Follow the existing POCKET_WIDGET_* convention: read with os.environ.get(...), default to a no-op when unset, and add a line to the debug-switches table in the README and in this guide.