A voice "awareness" sidecar for Claude Code, inspired by Thinking Machines' interaction models.
It adapts the two-tier pattern from that work:
- Background model — your existing coding agent. Claude Code or Codex (auto-detected, and it can watch both at once). Unchanged. It emits a live trace (thinking, tool calls, edits, results) that the sidecar reads from disk.
- Interaction model — a lightweight, always-present layer (this tool) that observes that trace, narrates it aloud, and answers your questions about what the agent is doing and why — in real time, while it keeps working.
This adapts the architecture, not the literal mechanism. There's no 200ms audio micro-turn fusion or co-training here — narration is template-driven and synthesis runs on a prompted Haiku.
claude (terminal A) ──writes──> session transcript (.jsonl)
│ tail
▼
┌───────────────── interaction layer (terminal B) ──────────────┐
│ templated narrator → instant, free → 🔊 say │
│ interaction model → Haiku, on demand → world-model + Q&A │
└───────────────────────────────────────────────────────────────┘
The mental model: Claude Code is an engineer thinking alone — dense, fast, idiosyncratic (raw numbers, half-formed hypotheses, terms it coins mid-stream). This tool is a peer engineer watching over your shoulder who translates that private stream into how one engineer briefs another.
- Voice = peer reasoning. Every ~15s, if the agent has done something with substance (a hypothesis, a result, a decision), the interaction model decides — like a colleague — whether there's anything worth saying, and says it in a sentence or two ("it thinks the 401 is from the refresh path, not verify, so it's reordering the middleware"). If nothing's new, it stays quiet. This is the "talk to me while it works" loop; it never interrupts you mid-question.
- Text feed = the play-by-play. Low-level actions ("Reading auth.ts") scroll
in the terminal but aren't spoken (use
--speak-actionsto voice them too). - Q&A answers your spoken/typed questions, grounded in the same stream.
Tune the cadence with IL_NARRATE_SECS (default 15). The LLM is called a few
times per minute — for proactive updates and Q&A — never once per tool call.
Run in a second terminal. With no argument it watches every active Claude session at once (one voice for all of them); pass a project dir to watch just that one:
node bin/interaction-layer.mjs # multi: watch ALL active sessions
node bin/interaction-layer.mjs <projectDir> # single: watch just this projectIt attaches to the live session(s) (see below) and starts narrating. Then:
- Hold Right Option (⌥) → narration stops, the mic turns on, it listens
- Speak, then release ⌥ → your words are transcribed locally and answered aloud
- Or tap an AirPod (play/pause) → toggle-to-talk (opt-in:
IL_AIRPOD=1).⚠️ Best-effort and unreliable on recent macOS. AirPod taps reach only the now-playing app, so the sidecar tries to claim that slot (silent audio via the bundled helper) — but macOS 26 often won't hand it over, so taps may not register. ⌥-hold is the dependable trigger. - Or type a question + Enter → spoken answer (works from any dictation tool too)
/agents/focus <name>/research <topic>/guardrails/memory/mute/unmute/quit
It also has persistent memory per project: across restarts it reloads the story so far and prior research findings (keyed by repo, so it survives new sessions and worktrees), and opens with a brief "picking back up on…" — while always deferring to the live transcript, so stale recall never overrides what's actually happening now.
One sidecar can follow many Claude sessions simultaneously — start claude in
three projects (or three cmux panes) and a single voice narrates all of them,
labelling each by project ("In webapp: it's now chasing the null deref…"). The
plugin auto-launches exactly one global sidecar; every later claude you start
just gets picked up by it.
When you talk back, your message goes to the agent the voice last spoke about
(it announces the target on a steer — "Sent it to webapp" — so you can correct
it). Switch deliberately with /focus <name>; see who's watched with /agents.
It can also start agents, not just watch them. Say "spin up an agent to write the integration tests" (or "spawn / start a new agent to…") and it:
- creates an isolated git worktree off the focused agent's repo on a new
il/<slug>branch, - launches a fresh
claudethere seeded with the task, running--permission-mode acceptEditsso it works unattended (override withIL_SPAWN_PERMISSION), - auto-discovers the new session and narrates/steers it like any other.
Because a worktree + branch are durable, it confirms first and names the repo
("spin up in mach to add tests? say yes") so a wrong target is caught before
anything is created. The task text is staged to a file and never enters the shell
command (injection-safe). Spawn requires explicit phrasing — "have it also…" steers
the existing agent, it doesn't spawn. No auto-merge yet; git worktree remove undoes a spawn.
For research-heavy work, the conductor critiques with outside knowledge. When the
agent makes a consequential, checkable technical choice (a named algorithm, a non-obvious
design decision), the conductor can ask itself a question and run a background literature +
reference-implementation review — a tool-using sub-agent (claude -p with web search) that
forms its own view of the canonical approach from sources it actually fetches, then compares
the agent's approach against it. It grounds every claim in a fetched source (no hallucinated
citations) and speaks up only when it finds a real divergence — a consistent approach is
noted silently, not announced. You can also ask directly ("how does this compare to the
canonical implementation?" / /research <topic>).
Cost is bounded: one research at a time, a per-session cap (IL_RESEARCH_MAX), it won't
re-research a topic it's already covered, and IL_AUTO_RESEARCH=0 disables the autonomous
side (explicit /research still works). Every auto-research is logged even when it stays quiet.
Two "interaction model" properties are built in:
- Push-to-talk (interrupt by voice). Hold Right Option (⌥) — a global hotkey, so it works while you're focused on the Claude terminal. Holding stops narration instantly and records; releasing transcribes locally via whisper.cpp (no API key) and answers. The mic process only runs while you hold the key — it's off otherwise.
- Continuous perception. While you talk, the transcript tail never pauses — the world-model keeps ingesting the agent's actions — so the answer is always current. Conversation mode suppresses the narration voice, never the watching.
Why a global key + Whisper? Terminals can't detect a key being held (stdin delivers characters, never key-up), so push-to-talk needs an OS-level monitor — a tiny Swift binary (bin/ptt-monitor.swift) watching the Right-Option modifier. Right Option is used because a global spacebar would fire every time you type a space.
One-time macOS permission: grant Accessibility to your terminal app
(System Settings → Privacy & Security → Accessibility), then fully quit and
reopen the terminal — the grant only takes effect on relaunch. Without it the
global ⌥ monitor silently receives nothing (it will warn NOACCESS). NSEvent key
monitors need Accessibility specifically, not Input Monitoring. Typed questions
work regardless.
Mic check (optional): node bin/mic-calibrate.mjs shows a live level meter so you
can confirm the mic hears you. Disable push-to-talk with --no-voice-in.
The default say voice (Samantha) is robotic. macOS has free Enhanced/Premium
neural voices that sound near-Siri:
- System Settings → Accessibility → Spoken Content → System Voice → Manage Voices…
- Expand English, download an Enhanced/Premium voice — good picks: Ava (Premium), Zoe (Premium) (US female), Evan/Nathan (Enhanced) (US male), Serena (Premium) (UK).
- Restart the sidecar — it auto-picks a Premium/Enhanced voice. Pin a specific
one with
IL_VOICE="Ava (Premium)"; adjust speed withIL_RATE(default 180).
Preview before committing: say -v "Ava (Premium)" "here's where the agent is at".
Set a Cartesia key and the tool uses Sonic automatically (falls back to say if a
call fails, so you're never silent):
export CARTESIA_API_KEY="…" # in ~/.zshrc so the auto-launched window inherits it
export IL_CARTESIA_VOICE="<voice-id>" # optional: a voice ID from cartesia.ai
export IL_CARTESIA_MODEL="sonic-2" # optional (default)For ElevenLabs instead, set an ElevenLabs key — the tool then uses it
automatically (falls back to say if a call fails, so you're never silent):
export ELEVENLABS_API_KEY="…" # in ~/.zshrc so the auto-launched window inherits it
export IL_ELEVEN_VOICE="21m00Tcm4TlvDq8ikWAM" # optional: a voice ID from your ElevenLabs account
export IL_ELEVEN_MODEL="eleven_flash_v2_5" # optional: fast + cheap (default)Force which engine with IL_TTS=cartesia|eleven|say. Playback uses afplay
(built-in). Auto-selection prefers Cartesia, then ElevenLabs, then local say.
It resolves which session to follow in priority order:
- Hook pointer (exact). When installed as a plugin, a
SessionStarthook writes~/.claude/interaction-layer/<project>.jsonwith the live session's realtranscript_path. The sidecar reads that and attaches to the exact file — no guessing.SessionEndmarks it ended. - Freshest transcript (fallback). Without the hook, it picks the
most-recently-modified transcript for the project and checks liveness (recent
mtime, or a running
claudeprocess).
It's resilient to ordering and lifecycle:
- Launched before Claude? It prints "no active session — watching…" and auto-attaches the moment a session starts.
- A new session begins? It switches to it automatically.
- Nothing live? It tells you, instead of silently tailing a dead transcript.
Clone it, install deps, and build the small Swift helpers:
git clone https://github.com/ManiacIncorporated/interaction-layer.git
cd interaction-layer
npm install
npm run build # compiles the Swift helpers (⌥ monitor, audio player, AirPod now-playing)Requirements: macOS, Node 18+, brew install sox whisper-cpp, and an LLM/TTS key
(set BASETEN_API_KEY or ANTHROPIC_API_KEY; optionally CARTESIA_API_KEY /
ELEVENLABS_API_KEY for a natural voice — otherwise it uses macOS say).
Two ways, depending on whether you want it automatic.
npm link # one time: puts `interaction-layer` on your PATHThen in any project (a second terminal, same dir as claude):
interaction-layer # attaches to that project's running Claude session/plugin marketplace add /path/to/interaction-layer # the cloned repo dir
/plugin install interaction-layer@interaction-layer # scope: user → all foldersNow the first claude you start auto-pops one sidecar in a new Terminal; it
then watches every other claude you start too (via the SessionStart hook +
pointer files). A global single-instance lock — claimed atomically, so several
sessions starting at once can't race — guarantees exactly one sidecar window, no
matter how many projects or cmux panes you open.
Controls:
IL_AUTOLAUNCH=0— disable auto-launch (still usable manually viainteraction-layer)- Per-project off: add
{"enabledPlugins": {"interaction-layer@interaction-layer": false}}to that repo's.claude/settings.json claude plugin uninstall interaction-layer@interaction-layer— remove entirely- The auto-launched terminal inherits your shell env, so export
BASETEN_API_KEY(orANTHROPIC_API_KEY) in~/.zshrcto get the fast backend automatically.
The installed plugin is a snapshot. After editing the source, re-run
/plugin marketplace update interaction-layer(or reinstall) to pick up changes.
The interaction model (world-model + Q&A) is pluggable. Auto-selection probes in order and uses the first that authenticates:
| Backend | Model | Enable with | Latency |
|---|---|---|---|
baseten |
GLM 5.2 (zai-org/GLM-5.2) |
BASETEN_API_KEY |
fast |
api |
Claude Haiku 4.5 | ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / ant auth login |
~1s |
sdk |
Claude Haiku 4.5 | nothing — reuses Claude Code auth | ~4–9s |
sdk always works out of the box but is heavy (it loads tool schemas we can't
strip). For snappy voice, set BASETEN_API_KEY (GLM 5.2, via Baseten's
OpenAI-compatible Model API) or an
Anthropic key. If the chosen backend errors mid-session, it degrades to sdk.
Force one with IL_BACKEND=baseten|api|sdk. Override models with IL_MODEL
(Claude backends) or IL_BASETEN_MODEL (Baseten slug).
- Live transcript tail + parse
- Templated voice narration (instant, free)
- Rolling world-model + Q&A (Haiku)
- Push-to-talk (hold ⌥) + local Whisper STT + continuous perception
- Exact session detection (SessionStart/End hook) + auto-attach + waiting/switch
- Plugin: auto-launch sidecar on
claudestart (single-instance lock) - Lean
apibackend as default once key handling is sorted - Configurable PTT key / cross-platform (Linux/Windows) key monitor
- macOS (
sayTTS;soxmic —brew install sox; Swift for the ⌥ monitor) whisper-cpp+ a model:brew install whisper-cppand aggml-*.en.binin~/.cache/interaction-layer/(override path withIL_WHISPER_MODEL)- Node 18+
- Accessibility permission for your terminal app (for the ⌥ push-to-talk key; relaunch the terminal after granting)