Talk to Claude Code. Hear it talk back. Approve, interrupt, or redirect β all by voice, from anywhere.
The core tech: a generic voice layer that can wrap any black-box agent using speech models (Gemini Live to hear, streaming text-to-speech to answer) for low-latency conversations. No modifications to the agent.
Duck Talk Claude Code
ββββββββ ββββββββββββββββ
You βspeechββΆ β STT β βinstββΆ β β
ββaudioββ β TTS β ββtxtββ β (any agent) β
ββββββββ ββββββββββββββββ
inst = instruction, e.g. "What is the latest PR?"
txt = raw stream of tokens
cd ~/code/the-project-you-want-to-talk-about
GEMINI_API_KEY=AIza... npx duck_talkThat's the whole setup. The folder you run it in is the project Claude works on β
its files, its git history, and its Claude Code conversations, including the ones you
started in a terminal. It is also where the relay keeps what it learns: a .duck-talk/
directory holding the turn records, the corrections, any prompt you edited from the
phone, and one log per run, kept for a week. Run it somewhere else tomorrow and it is about that instead. Then point a client
at the address it prints.
You will need:
- Claude Code, signed in β the relay's
last startup line names the account that will pay, asked of the CLI rather than
guessed from the environment. An
ANTHROPIC_API_KEYbills the API instead. GEMINI_API_KEYβ for the voice. The free tier works, no credit card. In the shell, or in a.envfile in that folder.
The command the package installs is duck-talk, and duck-talk --help has the rest:
--cwd serves a folder you are not standing in, --port defaults to 8765, --awake
(or KEEP_AWAKE=1 in .env) keeps the Mac from idle sleep while it runs. Node 22 or
newer.
The relay is a plain WebSocket server, so the client is separable from it. The one in
this repo is a SwiftUI iPhone app (app/) β talk on a walk, read the transcript,
browse and fork past conversations. cd app && ./dt run puts it on the simulator,
./dt phone on a cabled iPhone. See app/README.md.
The phone has to find the Mac, and the relay says how the moment it starts β one line for each place a phone can be:
simulator ws://localhost:8765
same Wi-Fi ws://192.168.1.42:8765
anywhere wss://your-mac.your-tailnet.ts.net
Copy the one that fits under gear βΈ Server in the app. The field checks the address as you type and says Reachable or what went wrong, so a wrong one fails right there, not a screen later.
Same Wi-Fi needs nothing. The Mac's own Wi-Fi address, plain ws:// β iOS
allows cleartext to a private address, and to nothing else.
Anywhere β cellular, another network, a cafΓ© β needs a tunnel, because your Mac is
behind your router with no public address of its own. Two things are not enough on
their own: forwarding port 8765 on the router gives you a public address, but iOS
refuses ws:// to it; only wss:// works off-LAN, and wss:// needs a real
certificate, which needs a hostname. Tailscale gives you all three at once with no
third party to trust:
- Install Tailscale on the Mac and on the iPhone, same account.
- In the admin console, turn on MagicDNS and HTTPS Certificates
(
login.tailscale.com/admin/dns). Once per tailnet. - On the Mac, once:
tailscale serve --bg 8765. It survives reboots.
The relay's anywhere line then shows wss://<your-mac>.<tailnet>.ts.net β no port
in it, because Tailscale answers on 443 with a Let's Encrypt certificate and forwards to
8765 itself. The relay stays a plain WebSocket server that knows nothing about TLS.
When any step is missing, that line says which one instead. And if the door is already
open onto a port nothing answers on β a relay you moved, or one no longer running β the
relay repoints it at itself and says so, rather than leaving you an address that
resolves and answers nothing.
Without Tailscale, the anywhere line says so, and the other two still work.
To check the Mac half with no phone at all:
node server/probe.ts "what is the latest commit"git clone https://github.com/dhuynh95/duck_talk.git && cd duck_talk
npm ci # exactly what the lockfile says β after every pull, too
npm start # the relay, watching, serving this repoThere is no build step in the loop: Node runs the TypeScript. npm run build exists
only for the package, which cannot assume that of a stranger's Node. A node_modules
older than the lockfile fails npm run check on SDK types that do not exist yet;
npm ci is the fix.
The iPhone app is a separate build with its own tooling β see
app/README.md βΊ Requirements.
The first version of this was a browser client against an Express backend. It is at
the web-app tag β git show web-app. Everything that mattered is in server/;
the one piece never ported is its audio calibration loop.
I wanted a coding assistant I could talk to on a walk β check on a long-running task, brainstorm architecture, review a plan. Hands-free, conversational, no laptop required.
STT tools like SuperWhisper and Wispr Flow get you halfway β you can dictate, but the agent never talks back. You can bolt TTS onto Claude Code via MCP, but you're waiting for the full response before hearing anything.
Voice-native agents like ChatGPT and Gemini Live have the conversation part down, but they're not connected to your codebase. They can't run commands, edit files, or see your project. And if your accent trips up the STT β "Cloud Code" instead of "Claude Code" β there's no way to catch it before it's sent.
Nothing combines all of this:
| Multi turn voice | Audio output | Low latency | No context bloat | Setup | |
|---|---|---|---|---|---|
| STT dictation | β Push-to-talk | β | β No response | β | β |
| MCP voice tool | β Keyboard | β | β After completion | β Extra MCP | β Custom MCP |
| Duck Talk | β | β | β | β | β |
- Real-time voice β talk to Claude Code hands-free. Say "stop" to interrupt mid-response.
- Streaming TTS β the answer is read a sentence at a time, as it streams. First audio in about a second, not after completion.
- Type or talk β the same conversation either way. A typed instruction opens no voice session at all and gets its answer on screen.
- Review mode β see the instruction before it runs. Fix it, send it, or ignore it. No more "Cloud Code" when you said "Claude Code."
- Correction learning β the fix is kept, and the next transcription starts from it: the recogniser is handed the words you actually use.
- Session management β browse, resume and fork conversations. It is Claude Code's own session store, so a chat you started at the desk carries on from your pocket.
The phone is a microphone, a speaker and a keyboard; the Mac holds the sessions. Claude Code is the black box in the middle, and the relay is the only thing that knows about all three.
iPhone (app/) Mac (server/) Anthropic + Google
mic ββΆ AudioPipe ββΆ VoiceSession βwsββΆ server.ts ββΆ session.ts ββΆ ears βββΆ Gemini Live (transcribes)
π ββ AudioPipe ββ VoiceSession ββwsβ server.ts ββ session.ts ββ voice βββ Gemini TTS (reads aloud)
β¨οΈ ββββββββββββββΆ VoiceSession βwsββΆ server.ts ββΆ session.ts ββ¬ββ claude ββΆ Claude Code (the agent)
βββ (no ears, no voice)
Audio is what buys audio: the ears open on the first microphone buffer and the voice speaks only where there are ears, so a connection that is only typed to reaches Claude and never Gemini β and nothing in the URL has to say which kind it is. The ears transcribe and nothing else, so a finished transcript is the instruction, and a partial arriving while Claude talks is an interruption.
The cost of putting the Mac in the middle measures about 1 ms. The wait you actually feel is Claude, and every turn records where its seconds went.
server/README.md has the wire protocol and the primitives, each
of which runs alone.
npm run check, npm run build, and ./scripts/install-elsewhere.sh β which packs
the tarball, installs it into a folder that is not this repo, and starts it there.
CI runs all three on every push, and so does the pre-commit hook npm install sets up
(scripts/hooks/pre-commit), on any commit that touches server/ or the package
config β so a package that cannot be installed is caught before it is committed.
git commit --no-verify skips it once.
Publishing is one button: Actions βΈ Release βΈ Run workflow, pick patch/minor/major.
That verifies, bumps, tags, publishes to npm with provenance, and writes the GitHub
release. It is the only thing that publishes, so the tag and the version on npm cannot
disagree. It needs one repository secret, NPM_TOKEN, which must be an npm
Automation token.