Hands-free voice over a real Claude Code terminal tab: say "computer, voice on", talk, and hear the session's replies read aloud a sentence at a time, then answer without touching the keyboard.
Status: work in progress. I use the system this comes from at my desk and it is still changing. This extracted version passes its tests against fakes; it has not yet been run against a live tab in this standalone form.
I wanted to keep coding with Claude Code when I'm away from the keyboard, by voice. I didn't want a separate voice assistant with its own memory. I wanted the same session I was typing in a minute ago, with its CLAUDE.md, skills and memory, and the chat history still on screen when I sit back down. So voice is a layer on top of a normal Claude Code tab: it types what I say into the tab and reads out what Claude writes back.
The first version (October 1) ran a separate headless Agent SDK session and showed its transcript in a window. It worked, but it was a second conversation next to my real one. The next day I moved it onto the real tab. The SDK version is still here as a fallback (sdk mode).
It sits behind the wake word of my home voice system. The router that decides which requests come here is published as voice-agent-router.
flowchart TD
W["wake word + speech-to-text<br/>(voice daemon, not in this repo)"] -->|"'voice on', 'keep coding', 'hold on'..."| C["Controller<br/>commands.parse()"]
C -->|attach| F["find the tab:<br/>kitty window foreground process = claude pid<br/>-> ~/.claude/sessions/PID.json"]
F --> G{"send gate<br/>session status"}
G -->|idle| K["kitty @ send-text<br/>'🎙 what you said' + Enter"]
G -->|waiting: a menu is open| R["refuse: 'It's waiting on a choice'"]
K --> T["Claude Code works in the tab as usual"]
T --> J["tail the session transcript (jsonl)"]
J -->|text block| S["speakable_code + sentence split<br/>-> say(text, ordered=True)"]
J -->|system / turn_duration| L["listen(max_s=25, silence_s=1.5)"]
L -->|a reply| C
L -->|silence| I["idle until the wake word"]
Finding the session. Every kitty window reports its foreground processes. If one is claude, its pid leads to Claude Code's own live record at ~/.claude/sessions/<pid>.json (session id, name, status, working directory). That record is the identity. It never goes from a session name to a file, because a claude job suspended with Ctrl-Z under the same shell leaves a record that looks just as alive. The record's procStart is compared with the process start time in /proc so a reused pid can't borrow a dead session's record.
Hearing the replies. The transcript is a jsonl file with one record per content block. JsonlTail follows it by byte offset, holds a half-written last line until its newline arrives, and skips subagent (sidechain) records. Each text block is spoken as soon as it lands. A system record with subtype turn_duration means the turn is over, so the mic opens for your answer. No Claude Code hooks are involved.
Making it sound right. speakable_code() rewrites each sentence before it is spoken: `MAX_TURNS` becomes "max turns", server.py becomes "server", --dry-run becomes "dry run", and paths and inline commands are dropped. A sentence that is still mostly symbols is not spoken at all; it is on screen anyway. When voice attaches it also types a short note into the chat asking for about 40 words per reply, plain sentences, and no paths or code names. Claude answers "Voice on." and that is the greeting you hear.
Typing safely. Text goes in with kitty @ send-text and then Enter, so the timing matters. The send gate reads the session status: idle sends; waiting (a permission dialog or a question picker is open) refuses, because Enter would answer it; shell (a background shell is running) sends only if the tab title shows the idle glyph; anything else waits, for up to 2 minutes. If the tab is the one in front, it also waits until the keyboard has been idle for 1.5 s, so your typing and the voice text don't interleave. Spoken turns are prefixed with 🎙 so they are easy to spot in the history.
What you can say. All of these work after the wake word. On the open mic (right after a reply) only exact short commands count, so "talk to me about the cache" or "keep coding until the tests pass" go to Claude as ordinary sentences.
| Say | What happens |
|---|---|
| voice on / talk to this one | attach to the tab in front |
| voice on name / talk to name | attach to a chat by name ("kodi ten" matches kodi10) |
| keep coding | re-attach to the last chat |
| switch to name | move voice to another chat, if one matches |
| let's code | open a new tab running Claude Code and attach |
| let's code project | an open chat by that name, otherwise a new tab in that folder |
| which chats are open | read the list |
| hold on / wait / stop | stop speaking and press Esc in the tab |
| voice off / that's all | detach (the tab gets a one-line "voice off" note) |
If two chats match a name about equally well it asks "A or B?". The attached tab is tinted purple while voice is on. After 10 minutes with no activity voice detaches by itself.
From the production log of the version this was extracted from. The log has times but no dates; the dates come from the session names, which carry them. These are SDK mode turns (the fallback), recorded 2026-10-01 and 2026-10-02. Tab mode has no timing lines in the log, so its latency is unmeasured.
| n | value | |
|---|---|---|
| Turns with timing lines | 10 | |
| Turns that spoke at least one sentence | 9 | |
| From sending the turn to the first sentence handed to text-to-speech | 9 | median 2.88 s, range 1.65 to 5.18 s |
| From sending the turn to the first streamed text | 9 | median 2.33 s |
n=9 is too small to call a rate or a typical latency. Treat it as two days of my own use on Opus. Whole-turn times ranged from 2.9 s to 110.8 s, depending on how much work Claude did.
You need:
- Linux with kitty as the terminal. This is a hard requirement: talk2code finds tabs and types into them through kitty's remote control. Add to
~/.config/kitty/kitty.confand restart kitty:allow_remote_control yes listen_on unix:/tmp/kitty - Claude Code installed and logged in (
claudeon your PATH). - Python 3.10 or newer. Tab mode uses only the standard library.
- Optional, X11 only:
xdotool(so "this one" means the window you are looking at) andxprintidle(the typing guard). On Debian/Ubuntu:sudo apt install xdotool xprintidle. - For
sdkmode only:pip install claude-agent-sdk. - For real voice: a voice daemon on
127.0.0.1:7781(see below). Without one, useconsolemode.
git clone https://github.com/JPInert/talk2code
cd talk2code
python3 -m unittest discover -s tests -t . # 45 tests, no kitty, daemon or claude neededpython3 -m talk2code tabs # list the Claude Code tabs it can see (read-only)
python3 -m talk2code console # type what you would say; replies print instead of speaking
python3 -m talk2code serve # production: POST /utterance on 127.0.0.1:7783
python3 -m talk2code sdk --cwd ~/src/myproject --console # fallback, no kitty tabIn console mode, open a Claude Code tab in kitty, bring it to the front, then type voice on in the talk2code terminal. When a reply finishes, the next line you type is your spoken answer.
talk2code needs two calls from whatever does the listening and speaking (talk2code/voice_io.py):
| Call | HTTP | Notes |
|---|---|---|
say(text, ordered=True) |
POST /say {"text", "ordered"} |
ordered = queue behind earlier lines, don't interrupt |
listen(max_s, silence_s) |
POST /listen {"max_s", "silence_s", "timeout_s"} -> {"heard": "..."} |
open the mic for one turn without the wake word |
converse(active) (optional) |
POST /converse |
route every unclaimed utterance here while attached |
cancel() (optional) |
POST /cancel |
stop speaking now |
tv_on() (optional) |
GET /status -> {"tv": true} |
keep the open mic shut while the room is noisy |
ConsoleVoice implements the same interface on a terminal. In serve mode the daemon sends each wake-word utterance to POST http://127.0.0.1:7783/utterance {"text": "..."} and gets back {"taken": true/false, "reply": "..."}; anything not taken is the daemon's to handle.
Settings are environment variables: VOICE_URL, TALK2CODE_PORT, TALK2CODE_CLAUDE_CMD (what a new tab runs, default cd {cwd} && claude; add your usual flags), TALK2CODE_PROJECT_DIRS (where "let's code project" looks, default ~ and ~/src), TALK2CODE_DEFAULT_PROJECT, TALK2CODE_TINT (empty to disable), TALK2CODE_KITTY_SOCKETS, TALK2CODE_MODEL (sdk mode), TALK2CODE_STATUSLINE (sdk mode transcript window).
- It reads Claude Code internals.
~/.claude/sessions/<pid>.json, itsstatusvalues and the transcript jsonl format are not a documented API. They worked with the Claude Code builds I used in October 2026 and could change in any release. - Hooks are not used, because mine were dead. The plan was to get turn-end events from Claude Code hooks. In my setup the hook scripts never reached the voice system in 25 days of logs (most likely a terminal check inside my own hook script). So the reply style is set by a visible "(voice on)" note typed into the chat instead of a hidden hook, and the turn end comes from the transcript.
- "Tell name ..." types into the focused tab. In the larger system there is a separate "tell name message" path that maps a session to its kitty window by tty, and
kitty @ lsdoes not report a tty, so it falls back to the focused tab. That path is not in this repo and talk2code does not use it; it attaches by foreground pid instead. It is listed here because it is still broken where it came from. - Short names can match the wrong chat. Name matching is fuzzy with a 0.6 threshold. "grocery" scores 0.62 against a chat called "router" and attaches to it. A test pins this behaviour so a fix will show up.
- No voice check on the open mic. After a reply the mic opens without the wake word, so a TV or another person can answer for you. I tried a speaker-verification gate; one evening it dropped 4 of my own real turns in under 10 minutes, so this version has no voice check on the open mic.
- Latency figures are small-n (9 turns, SDK mode only; see above).
- Typing guard and focus detection are X11 only. On Wayland the "tab in front" comes from kitty alone and the typing guard is skipped.
- Restoring a custom tab colour. Detaching resets the tab colour to kitty's default; it does not remember a colour you set yourself.
The voice daemon itself (wake word, speech-to-text, text-to-speech, the /say and /listen endpoints): it is tied to one room, one microphone array and recordings made there. The rest of the voice system that decides which utterances reach talk2code is in voice-agent-router. The SDK mode's spoken-permission callback is left out because it was switched off in favour of the same bypass setting as a typed session.
I built this with Claude Code as my pair, mostly while using it: the tab-attach design, the send gate and the open-mic rules each came from a bug I hit in a live voice session that week. Claude Code also extracted this standalone package from the working version and wrote its tests.
MIT