▶ 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.
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.
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.
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).
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.
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 how | Mechanism | Memory / 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. |
🖥 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.
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_HINTTurning 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_bufferThe 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()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_BRAINSAttaching 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_hostVad 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() / .levelOwns 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_hintThe 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()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()The headless end-to-end test; see §9 for what it covers and how to run it.
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()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.
| Key | Default | Notes |
|---|---|---|
voice | "alba" | a pocket-tts preset, or __my_voice__ after cloning |
mascot | "clippy" | selects the persona + which SVG shows |
model | default_brain() | first available CLI brain, else first OpenRouter one |
ears | true | always-on mic, persisted mute state |
vad_sens | 50 | 0–100, "hearing" knob |
patience_ms | 800 | pause length that ends a turn |
interrupt_ms | 450 | talk-over duration that barges in; 0 disables |
personas | {} | per-mascot system-prompt overrides |
ssh_hosts | unset | comma-separated, validated by valid_ssh_host |
asr_cmd | unset | bring-your-own transcription shell command, no UI |
drive_hint | see config.DEFAULT_DRIVE_HINT | three states: key absent = built-in default, "" = disabled, any other string = fixed override |
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.
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:
- Character: the mascot's voice and personality (
PERSONAS[mascot], or a user override fromconfig["personas"]saved through the ⚙ panel's personality editor). - Capability clause: exactly one of
NO_TOOLS_STYLE,READONLY_STYLE, orHANDS_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. - 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.
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.
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 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_hostrejects any host token starting with-, sincesshhas no--end-of-options and would otherwise parse-oProxyCommand=…as a local option, a local-RCE path closed at both write and read ofssh_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_sessionvalidates the fork id as a bare UUID and checks the resolved path stays inside the project directory before anyunlink/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_historyescapes 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:
| Covers | What it actually proves |
|---|---|
| TTS + visemes | the model synthesizes audio and the mouth-shape extraction sees real energy |
| Speaks-on-arrival | a sentence is voiced the moment it appears in the stream, not held until the stream ends |
| ASR round-trip | Moonshine transcribes the TTS's own output back correctly |
| Always-on ears | the full detect → endpoint → transcribe → emit pipeline, against a fake replayed mic |
| Barge-in + echo rejection | talking over him interrupts him; his own speaker echo never does |
| Speech filter | code fences, lists, table rows, and URLs never reach the voice unfiltered |
| Voice-prompt merge | a follow-up before the reply is audible extends the same prompt |
| drive_hint states | absent 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 brain | only 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 |
--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 var | Effect |
|---|---|
POCKET_WIDGET_EARS_DEBUG=1 | prints mic level vs. speech threshold (and barge-in progress) once a second |
POCKET_WIDGET_DEBUG=1 | opens the webview inspector |
POCKET_WIDGET_OPAQUE=1 | solid window fallback if transparency misbehaves |
POCKET_WIDGET_STAY_CURRENT=0 | disable catch-up: read a driven session's narration in strict order, even if it falls behind |
POCKET_WIDGET_NO_VAD=1 | force 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
- Add a character entry to
PERSONASinpersonas.py: just the character voice, not the capability/spoken-style plumbing (persona_for()appends that). - Optionally give it a default voice in
DEFAULT_VOICES(same file), any id fromconfig.PRESET_VOICES. Skip it and the mascot just keeps whatever voice was already selected when you switch to it. - 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. - Add it as an option to the mascot
<select>inui.html. - 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.