SLOPSHOPPER

claudio-vibecode

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.

newcommandstatusprocessnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudio-vibecode
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /vibe ⎿ claudio-vibecode: dev ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ claudio-vibecode: vibecode update available: /vibe update
README

Claudio vibecode

Vibe code with your voice, from anywhere.

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.

CI License: MIT Platforms Windows Phones

<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">

Why

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.

Voice, front and center

  • Speak your prompts. Tap Speak, say it, tap again. Your words land in the text box, so you can fix a word before you send. Use your phone's mic, or your computer's.
  • Hear every reply. Claude's short spoken summary plays as it finishes, on your phone or on your computer. Tap the speaker icon on any message to hear it again.
  • Keep the loop going. Speak, listen, speak. Vibe code on a walk, in the car park, or on the sofa.
  • Choose where you talk and listen. Mic and speakers are separate switches, each on PC or Phone.

And everything else you need

  • Read what Claude is doing, across every session you have open.
  • Show Claude what you see. Attach a photo, a screenshot or a file from your phone, or paste a copied picture.
  • Type when talking isn't an option. Your message arrives as if you had typed it at the keyboard.
  • Stop a task that is going the wrong way, with one tap.
  • Get notified when Claude replies while the page is in the background.

Install in a minute

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.

Pair your phone

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">

  1. In any Claude Code session, type /vibe on. It starts the hub and shows a QR code. A larger one opens in your browser too.
  2. Open your phone's camera and point it at the code. Your phone and computer need to be on the same Wi-Fi.
  3. Tap the link. The page opens, already paired.

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.

Take it with you

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.

  1. Install Tailscale on your computer and on your phone, and sign in to both with the same account.
  2. Turn on HTTPS. In the Tailscale admin console, under DNS, enable MagicDNS and HTTPS Certificates. It's a one-time switch.
  3. Run /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.
  4. Scan it with your phone, with the Tailscale app connected, and pair as above.

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.

A quick tour

<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>

Talk and listen your way

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.

PCPhone
MicTap Speak, talk to your computer's mic through Claude Code's voice mode, tap again to stopTap Speak, talk to your phone's mic, tap again to stop
OutClaude's voice plays on your computerClaude'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, /vibe serves 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.

Show Claude what you see

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.

  1. Tap Attach (the paperclip) in the text box. Your phone offers Take Photo, Photo Library and Choose File. You can pick several.
  2. Or paste a picture you've copied: long-press in the text box and tap Paste.
  3. Each file appears as a small chip above Send, with a thumbnail and a cross to remove it. Add your question and tap Send.

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.

Know when Claude replies

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:

  • It needs the secure (HTTPS) address, as above. Without it, the bell stays hidden.
  • It works while the page is still alive. Android Chrome keeps it going for a while, longer when Out is set to Phone. On iPhone, open the page in Safari and use Share, then Add to Home Screen. iOS puts background pages to sleep quickly.

Commands

CommandWhat it does
/vibe or /vibe onStart the hub and show a QR code to pair a phone
/vibe qrA fresh QR code, for another phone
/vibe statusAddress, paired phones, connected sessions, voice readiness
/vibe devices and revoke <id>List paired phones, or remove one
/vibe offStop the hub and close the port
/vibe updateInstall the newest release. update check only looks, update off stops the daily check

Secure by design

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

  • Closed until you open it. Nothing runs until you type /vibe. /vibe off closes the port at once, and it shuts itself down after 8 idle hours.
  • Pairing you control. The QR code holds a random, single-use code that expires in 5 minutes. Guessing is blocked too: pairing attempts are rate limited. The code is shown on your computer only, and the pairing page refuses anyone who isn't on that computer.
  • A key for every phone. After pairing, each phone gets its own 256-bit random token. It's stored on your computer only as a hash, never in plain text, and the cookie is HttpOnly and SameSite=Strict. Lose a phone? /vibe revoke removes it immediately.
  • No open doors. Every route needs that token, apart from the page itself. The routes used by Claude Code and by admin tools answer only on your own computer and need a separate secret.
  • Protected against other websites. Actions need a custom header and a matching origin, so a page you happen to have open in another tab can't make your phone or your computer do anything.
  • Encrypted when you leave home. With Tailscale, everything travels through a private, encrypted network and over HTTPS. There's no port forwarding, and nothing is exposed to the public internet.
  • Nothing leaves your network. No cloud, no account, no analytics, no tracking. The page is one file with no external requests, so nothing else loads when you open it. The server uses only Python's standard library, which keeps the code small and easy to audit.
  • Attachments are boxed in. Files you send are saved in a .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.
  • Open source. It's MIT licensed, and every line is there for you to read.

What to keep in mind

  • On plain Wi-Fi the connection is http, which isn't encrypted. Use a network you trust, or use Tailscale. /vibe status shows both addresses.
  • Anyone holding a paired, unlocked phone can use it like your keyboard. Keep your phone locked, and revoke it if it's lost.

How it works

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.

Questions

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.

Platforms

macOS is the tested platform. Windows is beta: automated tests only, see docs/windows.md. Linux is best effort.

Get involved

Found a bug? Open an issue. Output from claudio-vibecode doctor helps a lot. Pull requests are welcome, see CONTRIBUTING.md.

Uninstall

curl -fsSL https://raw.githubusercontent.com/restante/claudio-vibecode/main/install.sh | bash -s -- --uninstall

This removes claudio-vibecode and leaves claudio-tts alone.

Credits

License

MIT. See LICENSE.

Source 2 files
hooks/register.ts 237 lines
1import 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}
237
hooks/args.ts 44 lines
1const 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