Vibe code with your voice: run Claude Code from your phone. Read, speak, attach photos and files, stop a task and hear Claude, over a local web page.

Talk to Claude Code from your phone, show it a photo or a file, and hear it talk back. Start a task at your desk, walk away, and keep the conversation going with no keyboard and no monitor.
<img src="docs/images/hero.png" alt="The Claudio vibecode page on a phone: the conversation, Claude working with a Stop button, and the same page in dark mode" width="860">
Claude Code does its best work when you let it run. But a long task shouldn't keep you chained to the monitor.
Claudio vibecode is a free, open-source remote control for Claude Code, built around your voice. Use Claude Code from your phone, on iPhone or Android: say what you want, attach a photo or a file, hear Claude's answer read aloud, and say what's next. Claude Code remote, Claude Code mobile and Claude Code voice, in one small package.
It puts a small web page on your phone that is connected to the Claude Code sessions on your computer. Nothing to install on the phone, no account, and nothing leaves your network.
You need Claude Code with mods support and about 1 GB of free disk for the voice model. The installer fetches everything else, including claudio-tts, which provides Claude's voice.
macOS
curl -fsSL https://raw.githubusercontent.com/restante/claudio-vibecode/main/install.sh | bash
Windows (PowerShell, beta)
irm https://raw.githubusercontent.com/restante/claudio-vibecode/main/install.ps1 | iex
Then restart Claude Code. That's the whole install.
Pairing takes about ten seconds, and you only do it once per phone.
<img src="docs/images/pairing.png" alt="Typing /vibe on in Claude Code shows a QR code to scan with the phone's camera" width="760">
/vibe on. It starts the hub and shows a QR code. A larger one opens in your browser too.The code works once and expires in 5 minutes. Need another phone? Type /vibe qr for a fresh one. Done for the day? /vibe off closes everything.
On its own, the page works on your home or office Wi-Fi. To use it from the train, a café, or the other side of town, add Tailscale. It connects your phone and your computer over a private, encrypted network, so there's nothing to open on your router and nothing exposed to the internet. Tailscale has a free plan for personal use.
/vibe on. Claudio vibecode notices Tailscale and serves the page securely at https://<your-computer>.<your-tailnet>.ts.net:8443. The QR code points there.From then on, open the same address from anywhere with the Tailscale app on. Your computer needs to be awake with Claude Code running. /vibe status shows the address and confirms Phone mic: ready.
A bonus: the secure address is what unlocks the Phone mic and notifications, so Tailscale gives you the full experience, not just the distance.
Pair using the secure address. The Wi-Fi address and the Tailscale address are different places to your phone, and each needs its own pairing. Pair through the Tailscale one and your phone stays paired wherever you are.
<table> <tr> <td width="300"><img src="docs/images/idle.png" alt="The conversation view with a message, two attached files, the Send button, Mic and Out controls, and a Speak button" width="280"></td> <td valign="top">
Your sessions, at the top. Every Claude Code session on your computer is one tap away. A green dot means idle, amber means Claude is working.
The conversation. Your messages and Claude's replies. Replies show Claude's short spoken summary first. Tap Show full reply for the whole thing, or the small speaker icon to hear it read aloud.
One text box. It mirrors the prompt box on your computer. Type, or tap Speak and talk, then edit before you send. Clear appears only when there is something to clear, and Send appears only when there is something to send.
Attach anything. Tap Attach to take a photo, pick one from your library, or choose a file. You can also paste a copied picture into the box. Ask Claude about a screenshot, a crash log or a design, straight from your phone.
Mic, Out, and mute. Choose where you talk and where you listen. More on that below.
</td> </tr> <tr> <td width="300"><img src="docs/images/working.png" alt="Claude is working: a row at the end of the conversation shows the time so far and a Stop button" width="280"></td> <td valign="top">
You always know when Claude is busy. While it works, the conversation ends with a "Claude is working" row and the time so far. If it's heading the wrong way, tap Stop. It cancels the turn right away.
Light and dark. The page follows your phone's setting.
</td> </tr> </table>
Two independent choices sit right above the Speak button. The microphone icon is Mic, where you talk. The headphones icon is Out, where you listen. Each is PC (the monitor icon) or Phone.
| PC | Phone | |
|---|---|---|
| Mic | Tap Speak, talk to your computer's mic through Claude Code's voice mode, tap again to stop | Tap Speak, talk to your phone's mic, tap again to stop |
| Out | Claude's voice plays on your computer | Claude's voice plays on your phone only, and the computer stays quiet |
The speaker icon next to them mutes Claude's voice for the current session.
Mix them however you like. Talk to your phone and listen on your computer. Or leave your computer entirely: Mic on Phone, Out on Phone.
Phone mic needs HTTPS. Browsers only let a page use the microphone over a secure connection. With Tailscale installed and HTTPS certificates enabled,
/vibeserves the page securely and the Phone mic turns on by itself. Without it, use your keyboard's dictation key, or set Mic to PC. The speech is transcribed by your browser's speech service (Apple or Google), never by this project.
Point your phone at the problem. Attach a photo of an error on another screen, a screenshot of a bug, a crash log, a PDF spec or a design, then ask Claude about it.
Claude receives the files together with your message and reads them like any other file in your project. Photos are shrunk on your phone first, so a 12 MB photo uploads in a moment.
Files are saved in a .claudio-uploads folder inside your project. It's ignored by git and cleaned up after 24 hours. You can attach images, PDFs and text or code files, up to 8 per message and 20 MB each. More in Secure by design.
Tap the bell in the top corner and allow notifications. When Claude replies while the page is in the background, your phone shows a notification. Tap it to come back.
This works through the page itself, with no push service involved, so there are two honest limits:
| Command | What it does |
|---|---|
/vibe or /vibe on | Start the hub and show a QR code to pair a phone |
/vibe qr | A fresh QR code, for another phone |
/vibe status | Address, paired phones, connected sessions, voice readiness |
/vibe devices and revoke <id> | List paired phones, or remove one |
/vibe off | Stop the hub and close the port |
/vibe update | Install the newest release. update check only looks, update off stops the daily check |
Letting a phone talk to Claude Code is a serious thing, so security is built in from the start, not added later. A paired phone can make Claude run tools on your computer, so it's treated like a keyboard: nobody gets near it unless you let them, and it stays in your hands.
Why it's secure
/vibe. /vibe off closes the port at once, and it shuts itself down after 8 idle hours.HttpOnly and SameSite=Strict. Lose a phone? /vibe revoke removes it immediately..claudio-uploads folder inside the session's project, ignored by git and deleted after 24 hours. Only images, PDFs and text or code files are accepted, up to 20 MB each. Names are cleaned so a file can't escape that folder, and your phone never chooses a path: it can only refer to files the hub itself saved.What to keep in mind
http, which isn't encrypted. Use a network you trust, or use Tailscale. /vibe status shows both addresses.flowchart LR
C["Claude Code session"] -- "events (loopback)" --> H["claudio-vibecode hub<br/>(local web server)"]
H -- "prompt, stop, voice key" --> C
P["Phone browser"] <-- "page, live updates, audio" --> H
T["claudio-tts"] -- "speech" --> H
A small mod inside Claude Code forwards each session's prompts and replies to the hub on 127.0.0.1, and carries out what your phone asks for. The hub serves the phone page and relays Claude's voice from claudio-tts as short audio clips. The page is a single HTML file with no build step and no external requests. More in docs/how-it-works.md.
Does it work away from home? Yes, with Tailscale. See Take it with you.
Do I need Tailscale? No. On the same Wi-Fi it works as is. Tailscale adds the Phone mic, notifications, and use from anywhere.
Why does Speak with the PC mic ask for permission on my Mac? It presses Claude Code's voice key for you in the front window, so your terminal needs Accessibility access: System Settings, Privacy & Security, Accessibility. On Linux, install xdotool. Keep the screen unlocked.
Can I send Claude a photo or a file? Yes. Tap Attach, or paste a copied picture. See Show Claude what you see.
Which phones work? iPhone and Android, in the phone's normal browser. Notifications on iPhone need Safari and a Home Screen page.
What does it cost? Nothing. It's MIT licensed.
macOS is the tested platform. Windows is beta: automated tests only, see docs/windows.md. Linux is best effort.
Found a bug? Open an issue. Output from claudio-vibecode doctor helps a lot. Pull requests are welcome, see CONTRIBUTING.md.
curl -fsSL https://raw.githubusercontent.com/restante/claudio-vibecode/main/install.sh | bash -s -- --uninstall
This removes claudio-vibecode and leaves claudio-tts alone.
MIT. See LICENSE.
hooks/register.ts 237 lines1import type { Hook, Register } from 'claude-code'
2
3import { type Attachment, parseVibeArgs, summaryOf, withAttachments } from './args'
4
5const USAGE =
6 'Usage: /vibe [on|off|qr|status|devices|revoke <id>|update [check|on|off]]'
7
8type Dollar = Parameters<Hook<'turn.start'>>[0]
9
10// Everything with an OS-specific job (the web hub, the voice key, pairing) lives in the
11// `claudio-vibecode` Python package; this mod forwards events to the hub on loopback and carries
12// out what the phone asked for. CLAUDIO_VIBECODE_PYTHON is set by `install-mod`.
13async function python($: Dollar) {
14 try {
15 return (await $.env.get('CLAUDIO_VIBECODE_PYTHON')) ?? 'python'
16 } catch {
17 return 'python'
18 }
19}
20
21async function cli($: Dollar, args: string[], timeoutMs = 15000) {
22 const exe = await python($)
23 try {
24 return await $.process.run([exe, '-m', 'claudio_vibecode', ...args], { timeoutMs })
25 } catch (error) {
26 $.ui.log(`claudio-vibecode: ${String(error)}`, { to: 'debug' })
27 return undefined
28 }
29}
30
31async function sessionId($: Dollar) {
32 try {
33 return await $.session.id()
34 } catch {
35 return 'default' // only when the engine cannot name the session (tests)
36 }
37}
38
39type Hub = { port: number; token: string; pid: number }
40type PhoneCommand =
41 | { type: 'prompt'; text: string }
42 | { type: 'cancel' }
43 | { type: 'submit'; text?: string; attachments?: Attachment[] }
44 | { type: 'setdraft'; text: string }
45 | { type: 'mute'; on: boolean }
46type Polled = { commands: PhoneCommand[]; known: boolean }
47
48let hub: Hub | undefined
49let currentTurn: string | undefined
50let tries = 0
51let busy = false
52let lastDraft = ''
53
54async function hubCall($: Dollar, method: 'GET' | 'POST', path: string, body?: unknown) {
55 if (!hub) return undefined
56 try {
57 const res = await $.http.fetch(`http://127.0.0.1:${hub.port}${path}`, {
58 method,
59 headers: { 'X-Claudio-Mod': hub.token, 'Content-Type': 'application/json' },
60 body: body === undefined ? undefined : JSON.stringify(body),
61 })
62 return res.ok ? JSON.parse(res.text) : undefined
63 } catch {
64 hub = undefined // the hub went away; the next tick looks for it again
65 return undefined
66 }
67}
68
69async function registerWithHub($: Dollar) {
70 let cwd = ''
71 try {
72 cwd = String(await $.session.cwd())
73 } catch {
74 /* unnamed session */
75 }
76 let term = ''
77 try {
78 term = (await $.env.get('TERM_PROGRAM')) ?? ''
79 } catch {
80 /* unknown terminal */
81 }
82 await hubCall($, 'POST', '/api/mod/register', {
83 session: await sessionId($),
84 name: cwd.split(/[\\/]/).filter(Boolean).pop() ?? '',
85 cwd,
86 term,
87 pid: hub?.pid ?? 0,
88 })
89}
90
91async function connect($: Dollar) {
92 const found = await cli($, ['info'], 8000)
93 if (!found || found.exitCode !== 0) return
94 try {
95 const info = JSON.parse(found.stdout) as { port: number; token: string; pid?: number }
96 hub = { port: info.port, token: info.token, pid: info.pid ?? 0 }
97 } catch {
98 return
99 }
100 await registerWithHub($)
101}
102
103function post($: Dollar, event: { kind: string; text?: string; summary?: string; status?: string }) {
104 if (!hub) return
105 void sessionId($).then(session => hubCall($, 'POST', '/api/mod/event', { session, ...event }))
106}
107
108async function tick($: Dollar) {
109 if (busy) return
110 busy = true
111 try {
112 if (((await $.store.get('on')) ?? false) !== true) {
113 hub = undefined
114 return
115 }
116 if (!hub) {
117 if (tries++ % 10 !== 0) return // look for the hub about every 10 s
118 await connect($)
119 if (!hub) return
120 }
121 const session = await sessionId($)
122 const polled = (await hubCall($, 'GET', `/api/mod/poll?session=${encodeURIComponent(session)}`)) as
123 | Polled
124 | undefined
125 if (!polled) return
126 if (!polled.known) await registerWithHub($)
127 for (const command of polled.commands) {
128 if (command.type === 'prompt') {
129 void $.prompt.submit({ text: command.text, asUser: true }).catch(() => undefined)
130 } else if (command.type === 'submit') {
131 // Send the text from the phone (or, failing that, the prompt box), as pressing Enter would.
132 const draft = command.text?.trim() || (await $.prompt.read().catch(() => undefined))?.text.trim()
133 if (draft || command.attachments?.length) {
134 await $.prompt.fill({ text: '', mode: 'replace' }).catch(() => undefined)
135 lastDraft = ''
136 void $.prompt
137 .submit({ text: withAttachments(draft ?? '', command.attachments), asUser: true })
138 .catch(() => undefined)
139 }
140 } else if (command.type === 'mute') {
141 // The sound switch belongs to claudio-tts; ask it the way a person would.
142 await $.command
143 .run({ command: 'tts', args: command.on ? 'mute' : 'unmute' } as never)
144 .catch(() => undefined)
145 } else if (command.type === 'setdraft') {
146 lastDraft = command.text // the phone's own edit: do not echo it back
147 await $.prompt.fill({ text: command.text, mode: 'replace' }).catch(() => undefined)
148 } else if (currentTurn) {
149 await $.turn.abort({ turnId: currentTurn }).catch(() => undefined)
150 }
151 }
152 // Show the phone what is in the prompt box, so a dictated message can be read before it is sent.
153 const box = await $.prompt.read().catch(() => undefined)
154 if (box && box.text !== lastDraft) {
155 lastDraft = box.text
156 await hubCall($, 'POST', '/api/mod/draft', { session, text: box.text })
157 }
158 } finally {
159 busy = false
160 }
161}
162
163// Once a day (the Python side caches), say if a newer release exists. Never installs anything.
164async function noticeUpdate($: Dollar) {
165 if (((await $.store.get('updateCheck')) ?? true) !== true) return
166 const found = await cli($, ['update', '--quiet'], 8000)
167 if (found?.stdout.trim()) $.ui.status('vibecode update available: /vibe update')
168}
169
170export const register: Register = on => {
171 on('session.start', async ($, e, next) => {
172 await $.command.register({
173 name: 'vibe',
174 description: 'Control Claude from your phone: /vibe [on|off|qr|status|devices|revoke|update]',
175 })
176 $.clock.every(1000, () => tick($))
177 await noticeUpdate($)
178 return next(e)
179 })
180
181 on('turn.start', async ($, e, next) => {
182 currentTurn = e.turnId
183 post($, { kind: 'prompt', text: e.text, status: 'working' })
184 return next(e)
185 })
186
187 on('turn.complete', async ($, e, next) => {
188 if (e.agentId === undefined) {
189 currentTurn = undefined
190 post($, { kind: 'reply', text: e.answer, summary: summaryOf(e.answer), status: 'idle' })
191 }
192 return next(e)
193 })
194
195 on('session.end', async ($, e, next) => {
196 await hubCall($, 'POST', '/api/mod/unregister', { session: await sessionId($) })
197 return next(e)
198 })
199
200 on('command.run', { command: 'vibe' }, async ($, e) => {
201 const cmd = parseVibeArgs(e.args)
202 if (!cmd) return { text: USAGE }
203
204 if (cmd.kind === 'on' || cmd.kind === 'qr') {
205 await $.store.set('on', true)
206 const started = await cli($, ['start'], 20000)
207 if (!started || started.exitCode !== 0) {
208 return { text: started?.stderr.trim() || started?.stdout.trim() || 'Could not start vibecode.' }
209 }
210 tries = 0
211 await connect($)
212 const shown = await cli($, ['qr', ...(cmd.kind === 'qr' && cmd.invert ? ['--invert'] : [])], 20000)
213 return { text: shown?.stdout.trim() || shown?.stderr.trim() || 'Vibecode is on.' }
214 }
215 if (cmd.kind === 'off') {
216 await $.store.set('on', false)
217 await hubCall($, 'POST', '/api/mod/unregister', { session: await sessionId($) })
218 hub = undefined
219 const stopped = await cli($, ['stop'])
220 return { text: stopped?.stdout.trim() || 'Vibecode is off.' }
221 }
222 if (cmd.kind === 'update') {
223 if (cmd.value === 'on' || cmd.value === 'off') {
224 await $.store.set('updateCheck', cmd.value === 'on')
225 return { text: `Update check at session start: ${cmd.value}` }
226 }
227 // Typing /vibe update is the approval; `check` only looks.
228 const done = await cli($, cmd.value === 'check' ? ['update', '--check'] : ['update', '--yes'], 300000)
229 return { text: done?.stdout.trim() || done?.stderr.trim() || 'Could not run the update.' }
230 }
231 const args =
232 cmd.kind === 'revoke' ? ['revoke', cmd.value] : [cmd.kind]
233 const done = await cli($, args)
234 return { text: done?.stdout.trim() || done?.stderr.trim() || 'Could not run that.' }
235 })
236}
237hooks/args.ts 44 lines1const OPEN = '<!-- TTS_SUMMARY'
2const CLOSE = 'TTS_SUMMARY -->'
3
4/** The spoken summary of a reply (the TTS_SUMMARY block), or '' when it has none. */
5export const summaryOf = (text: string): string => {
6 const start = text.indexOf(OPEN)
7 if (start === -1) return ''
8 const rest = text.slice(start + OPEN.length)
9 const end = rest.indexOf(CLOSE)
10 return end === -1 ? '' : rest.slice(0, end).trim()
11}
12
13export type VibeCommand =
14 | { kind: 'on' | 'off' | 'status' | 'devices' }
15 | { kind: 'qr'; invert?: boolean }
16 | { kind: 'revoke'; value: string }
17 | { kind: 'update'; value?: 'check' | 'on' | 'off' }
18
19/** Parses `/vibe` arguments; undefined when they make no sense. No argument turns it on. */
20export const parseVibeArgs = (args: string): VibeCommand | undefined => {
21 const [word = '', ...words] = args.trim().toLowerCase().split(/\s+/)
22 const rest = words.join(' ')
23 if (word === '' || word === 'on' || word === 'start') return { kind: 'on' }
24 if (word === 'off' || word === 'stop') return { kind: 'off' }
25 if (word === 'status') return { kind: 'status' }
26 if (word === 'devices' || word === 'phones') return { kind: 'devices' }
27 if (word === 'qr' || word === 'pair') return rest === 'invert' ? { kind: 'qr', invert: true } : { kind: 'qr' }
28 if (word === 'revoke') return rest ? { kind: 'revoke', value: rest } : undefined
29 if (word === 'update' || word === 'upgrade') {
30 if (rest === '') return { kind: 'update' }
31 if (rest === 'check' || rest === 'on' || rest === 'off') return { kind: 'update', value: rest }
32 }
33 return undefined
34}
35
36export type Attachment = { name: string; path: string }
37
38/** The prompt as Claude gets it: the text, then where the files from the phone are saved. */
39export const withAttachments = (text: string, files: readonly Attachment[] | undefined): string => {
40 if (!files?.length) return text
41 const body = text.trim() || `Please look at the attached ${files.length === 1 ? 'file' : 'files'}.`
42 return `${body}\n\nAttached from my phone (read these files):\n${files.map(f => `- ${f.path}`).join('\n')}`
43}
44