cc-voice-gateway
Claude Code, by voice. Talk to Claude in the Claude app's voice mode, in the car, on a walk
or at your desk, and Claude Code sessions on your own computer do the work: answer questions about
your repositories, change code, push branches, open, review and merge pull requests. Plain
JavaScript (ESM, Node.js), no build step.
This README is the technical documentation. What it is for, in a two-minute video, is on the
product page.
| What it does | Details |
|---|
| Voice control | Claude's voice mode, through a custom MCP connector, as the front end of Claude Code: in the car with CarPlay or Android Auto, on the phone, or at the desk next to mouse and keyboard. Answers are written to be heard; approvals by voice | Overview, voice commands |
| Three layers of AI | The voice AI (Claude voice mode) hands each request to the controller, a Claude Code session on your computer reached through a channel. The controller keeps the context, dispatches workers (headless claude -p runs) and sums up their results. The gateway in between has no AI of its own | Overview, architecture |
| Parallel workers | Several repositories at once, several runs per repository in git worktrees of their own, a queue that survives restarts, work folders for tasks without a repository; on this computer or in Docker | Workers, several runs, work folders |
| It comes back to you | Results as spoken news; questions and approvals on Telegram once the call has ended, and if you turn it on, questions answered there by Reply and requests written there as to the controller itself, with its replies in the chat; pull requests watched, review comments fixed automatically; open items and reminders; the controller in the Claude app with Remote Control | Telegram, open items, review fixes, Remote Control |
| Safe by design | Pull requests on voice/ branches; a merge after your yes and an approval; workers only on your spoken words; Docker access levels set by the GitHub token; a confined controller; a kill switch in code; sign-in with a pairing code, /mcp only from Anthropic's addresses | Security design |
[!WARNING]
Driving comes first. When you use this in the car, keep your full attention on the traffic,
not on the conversation or the work. It is built so that you can, at any moment: open items and
unanswered questions are kept, and when you come back, ask for the status and everything is
listed, brought up to date and followed up.
Use at your own risk. Follow the traffic rules and laws where you drive, including those on
phone use. The authors accept no liability for accidents, injuries or damage of any kind arising
from its use; see also sections 7 and 8 of the licence.
Provided as-is, as a prototype. This is solely a prototype of an idea: a way to use Claude
Code from Claude's voice mode, which cannot reach Claude Code on its own. It comes without
warranty and without support, and there is no communication of any kind about this project: no
announcements, no support, no answers to issues or messages. It is not an Anthropic product.
Used with Claude voice mode in the Claude app, on the phone and in the car with CarPlay or Android
Auto; tested so far only on the iPhone and with CarPlay. Claude Code channels are a research
preview; their flags and protocol may change.
Licensed under the Apache License 2.0.
Getting started
From a clone to the first voice request, on a computer with the prerequisites:
Node.js 20 or later, Claude Code logged in with claude.ai, gh logged in, tmux and Tailscale. On
Team and Enterprise an Owner first does the admin steps.
Linux or macOS (install and test on a Mac); on Windows, inside WSL.
-
Clone the repository with the GitHub CLI:
gh repo clone alderg/cc-voice-gateway && cd cc-voice-gateway
You are in the new folder cc-voice-gateway.
-
Install the dependencies:
npm install
95 packages land in node_modules; nothing is compiled (what it installs).
-
Run the setup assistant, a temporary web server on this computer that writes the
configuration files and then helps you connect the Claude app. Without it, the gateway runs with
its defaults.
node scripts/setup.mjs
It prints a one-time link per address of this computer, http://<address>:8790/?t=<token>. Open
one in a browser on your own network, since it is plain HTTP; from elsewhere use an SSH tunnel.
The page writes .secrets/gateway.env, plus Telegram, Docker workers and the autostart if you
choose them (7.1, files). Tick "Start
it now" in its section 7, and leave "keep this page open" ticked in section 9: after writing,
the assistant stays up, and its section 10, "Connect the Claude app", goes with you through
steps 4 to 7, with the connector URL and the pairing code. No second terminal is needed for them.
-
The controller runs in the tmux session voice once the assistant started it; without the
assistant, start it yourself. It comes before the funnel and the pairing code: Claude Code starts
the gateway inside it, and both need the running gateway.
tmux new -s voice scripts/start-controller.sh
Claude Code opens in controller/, and until it is through its questions at the terminal, the
gateway does not start. Its folder trust question does not come: the setup assistant and the
start script mark controller/ as trusted in Claude Code's config beforehand
(8.1). With the development flag, the channel warning comes at
every start: attach with , press Enter, and detach with Ctrl-b, then d.
Should Claude Code ask "Is this a project you created or one you trust?" all the same, press the
down arrow once to "Yes, I trust this folder", then Enter: Enter alone picks "No, exit". While
the assistant runs, its own terminal is busy: attach from another one on this computer, or over
SSH. While the tmux session runs and the gateway does not answer, section 10 of the page says
how, names the question when it sees it in the tmux session, and notices by itself when the
gateway is up.
Then try it in voice mode with the same account: "Ask Claude Code whether the gateway is up."
Contents
- Overview
- Motivation
- Comparison with existing solutions
- Diagrams
- Security design
- Prerequisites
- Installation
- Operation
- Repository layout
- Sources
1. Overview
cc-voice-gateway connects two things that do not talk to each other: Claude's voice mode in the
Claude app, and Claude Code on your computer. It is one Node.js process with two sides:
- Towards the Claude app: an MCP server over HTTPS. You add it in the Claude app as a custom
connector. Claude's servers call its tools (
send, ask, get_reply, status, approve,
emergency_stop) during a voice conversation, after an OAuth sign-in with a pairing code from
your computer.
- Towards Claude Code: a channel, an MCP server that pushes each voice request into
a running Claude Code session, the controller. The controller answers through the channel's
reply tool. By default it may read its own folder and the status of pull requests, and does no
repository work itself; two higher levels let it also clone
and delete, or do everything. At every level it does only quick lookups itself and hands anything
longer off at once, several jobs side by side, to a worker, a work folder, or at unrestricted a
subagent in the background, so that it stays free for your next request.
Work in a repository goes to a worker: a headless claude -p run in that repository, started
by the gateway when the controller calls dispatch. One run per repository, two at a time by
default; with a higher limit per repository, further runs work next to the first in
git worktrees of their own. Workers run in your checkouts, or in a Docker container whose GitHub token sets what they
may publish: nothing, pull requests, or also a direct push to the default branch. A worker's
result reaches the controller as a channel event; the controller summarises it, and you hear it
as news in the voice conversation. If it asks you something after the conversation has ended,
the question also goes to Telegram. Optionally, a task that needs no repository, such as a
question to look up, a calculation or a draft, gets a work folder of its own, on
this computer or in a container.
| Part | Code | Runs where | Role |
|---|
| Gateway | src/gateway.mjs and the rest of src/ | Started by Claude Code, inside the controller session | State, relay, OAuth, workers, kill switch. No AI |
| Controller | Claude Code in controller/ | Your computer, in tmux, under a watchdog | Understands, answers, dispatches, asks back |
| Workers | claude -p | Your checkouts, or a Docker container | The work in the repositories |
| Voice AI | Claude app, voice mode | Anthropic's cloud | The conversation with you |
| Tunnel | Tailscale Funnel or a reverse proxy | Your computer or a server | Public HTTPS address for Claude's servers |
| Setup assistant | scripts/setup.mjs | Your computer, only while setting up | Writes the settings and secrets into .secrets/ |
2. Motivation
The goal: talk to Claude, for example while driving, and have real work done in your
repositories, with results and questions coming back by voice, without touching a screen.
Claude does not offer this by itself. In the Claude app, voice mode is not available for Claude
Code; there it only has dictation (voice mode). Claude Code Remote Control reaches a local
session from the app, but in text (Remote Control). Voice mode can use connected tools
(voice mode); the help article does not name custom MCP connectors
(connectors), but in our use voice mode calls them. So the bridge is a connector that
Claude's voice mode calls, and that hands each request to Claude Code.
What this project tries, and why:
| Decision | Reason |
|---|
| Use Claude's own voice mode as the front end | No speech recognition or speech output of its own. Claude understands the request in any language, keeps the conversation's context and reads the answer out |
Long polling with short calls (send, then get_reply for at most 40 s) | MCP clients often abort a tool call after 60 s (talk-to-claude-code). Work takes minutes |
| A running Claude Code session (the controller) as the receiver, through a channel | It keeps context across requests, asks back and summarises. A fresh claude -p per request would forget everything |
| Workers as separate headless runs | The controller stays responsive and confined; several repositories can be worked on at once; a run survives a controller restart |
| Approvals relayed to the voice conversation | The channel's permission relay lets Claude Code's approval prompts be answered by voice |
| Telegram as an optional fallback for what needs you | Voice assistants only poll. Once you hang up, a question or an approval would wait until the next conversation. Results can wait; they stay off Telegram |
| Docker with access levels derived from the GitHub token | Host workers run as you, with your logins. A container with a scoped token limits what a run can reach and publish |
| Changes out of a read-only container as a git bundle into a clean clone | Fetching from the container's folder would run git with config that the container controls |
| A kill switch in the gateway's code | A lock that does not depend on the controller following instructions |
| A watchdog, and the channel as an allowlisted plugin | The session has to come back after a crash without someone at the terminal. With the development flag, Claude Code asks at every start; an organisation allowlist is the supported way around it |
| A setup assistant | Many keys, files and steps; a page with a guide per field is less error-prone than a long manual |
Things that did not work and were replaced:
- Claude Code's Bash sandbox for the controller: on Ubuntu 24.04 and later, AppArmor's
bwrap-userns-restrict profile stops it from starting (sandboxing). A PreToolUse hook
with a command allowlist confines the controller instead.
- Tailscale Funnel on port 8443: Funnel serves it publicly, also with a valid certificate, but
claude.ai's connector did not connect to it in our tests ("no server answered", and no request
in the log); port 443 works.
- Another web server's port 443 on the gateway's computer limited to 127.0.0.1: the Tailscale
app on macOS still did not take 443 on the tailnet address
(known issues).
- Skipping the development-channel prompt: there is no supported way for a channel that is not
on the approved list (channels reference). The plugin on the organisation's allowlist is
the way without a prompt.
git fetch from the container's folder: it starts git upload-pack in a repository the
container controls. A bundle is plain data (details).
3. Comparison with existing solutions
Checked on 2026-10-28 against the sources linked in each row. "Unverified" means no source
confirmed it.
| Voice front end | What it controls | Where the code runs | Car | Source and licence |
|---|
| cc-voice-gateway | Claude's voice mode in the Claude app, through a custom MCP connector | A running Claude Code session (channel), which dispatches headless workers | Your computer; workers optionally in Docker | CarPlay or Android Auto, wherever the Claude app's voice mode runs; tested with CarPlay | This repository, Apache 2.0 |
| carcode | Siri: a Siri Shortcut ("Hey Siri, Jarvis") on the iPhone | The Claude Code CLI on your Mac, with your login | Your Mac, reached through a Cloudflare quick tunnel; the server can run on Linux, the Shortcut needs macOS once | CarPlay, through Siri | Open source, MIT |
| DashVox | Its own iPhone app, with CarPlay and Apple Watch | Claude Code, Claude Cowork, Codex or another CLI, over SSH on your machines | Your machines; the app's backend is DashVox Cloud or self-hosted | CarPlay | App free, with a paid tier; source code unverified |
| VoiceMode | Microphone and speakers of the computer; speech recognition and output local (Whisper.cpp, Kokoro) or through OpenAI-compatible services | Claude Code itself, as an MCP server or plugin | Your computer | None documented | Open source, MIT |
| Claude Code Remote Control | None: the Claude app and claude.ai offer dictation, not voice mode, for Claude Code (voice mode) | A local Claude Code session, from claude.ai/code or the Claude app | Your computer, outbound HTTPS only, no inbound port | No | Anthropic. Pro, Max, Team, Enterprise; on Team and Enterprise an Owner enables it |
| Claude for Android Auto | Unverified: no official or third-party Claude app for Android Auto was found |
For the car itself, the Claude app supports CarPlay (MacRumors, 2026-09-04); this
project uses whatever voice mode the app offers there. Nothing in it is specific to CarPlay, so
Android Auto should work the same where the app offers voice mode; so far it was tested only with
CarPlay.
What this project adds over the others:
- Claude is the voice. No Siri phrase, no speech pipeline of its own. The same assistant that
understands you also decides what to hand to Claude Code, and reads back a short answer in your
language.
- A controller and workers. One conversation drives a persistent session that knows what you
asked before, and several repositories at once. Results arrive later as news; questions from a
worker come back to you.
- Approvals and merges by voice. Claude Code's approval prompts reach the conversation. A merge
needs your spoken yes and then an approval (confinement).
- Telegram after the call, optional, for questions, approvals and alerts, and if you turn it
on, for requests and answers you write there; push, merge, restart, delete and approvals stay
with your voice.
- Isolation as a choice. Workers on the host, or in Docker at a level that the GitHub token
decides, with a defined path out for read-only containers.
- A kill switch in code, with triggers by voice, Telegram, fixed rules and the controller.
What it costs:
- A public HTTPS endpoint that Claude's servers can reach.
- A research-preview feature: channels, with the development flag or an organisation
allowlist. Team and Enterprise need an Owner for channels and connectors.
- More moving parts than carcode or DashVox.
- Others do some things better: carcode installs with one
npx command; DashVox has a
polished app with an Apple Watch and several agents; VoiceMode works at the desk without any
cloud voice; Remote Control is supported by Anthropic and needs no inbound connection.
4. Diagrams
All diagrams are made with draw.io and saved as .drawio.svg: an SVG
image with the diagram embedded, so GitHub shows it as a picture and draw.io opens it for editing.
In VS Code, the vscode-drawio extension edits them
inline, right in the repository.
The product page follows the system's light or dark setting. In dark mode it shows a copy of the
architecture diagram exported with draw.io's dark theme. After changing the diagram, export that
copy again:
drawio -x -f svg --theme dark -o docs/assets/architecture-dark.svg docs/architecture.drawio.svg
drawio is the command line of the draw.io desktop app; on macOS it is
/Applications/draw.io.app/Contents/MacOS/draw.io.
4.1 Architecture
See the diagram in the Overview. The gateway is the only own code at run time;
everything blue is Claude Code.
4.2 A request
The voice AI calls send and then get_reply until the state is done. When you ask for
something, its reply begins with the tool call, and it speaks once the call returns. send returns
at once, and right after it the voice AI confirms in a few words of your language what it passed on,
fitting the request and worded differently each time, such as "Der Abgleich der Branches ist
unterwegs", "Die Frage zu den Tests ist bei Claude Code" or "Neustart ist angefragt". ask waits
only briefly for a quick answer, 8 seconds by default (VOICE_GW_ASK_WAIT), since you hear nothing
until it returns; then the voice AI reads the answer, or confirms the same way while it still runs.
After approve it confirms your decision in a short phrase ("Erlaubt", "Abgelehnt"); between polls
it says in a short phrase that it is still running ("Läuft noch"). The instructions and the tool
descriptions are short and say only what to do, with examples of what fits, once each, and name no
sentences to avoid: a list of those made the voice AI say them more often, and a rule to answer "at
once" invites a sentence before the call. It never stays silent: in a car silence sounds as if you
were not heard, so the instructions ask for an answer to everything you say, the results of send
and of a running ask ask for the confirmation, and that of a poll without news for a short phrase.
A beep would not do, since it sounds like an error. Every result starts with a
machine-readable line, for example [state=working id=k7f3 elapsed=34s]. Requests and approvals
have these states:
4.3 Setup
4.4 Variants
| Variant | Turned on by | Effect |
|---|
| Voice only | default | News reaches you when the voice AI calls |
| Voice + Telegram | .secrets/telegram_token and .secrets/telegram_chat_id | After 10 minutes without a voice call, questions and approvals also go to Telegram; results wait for the next call. With Telegram input on (off by default), a message there is a request or an answer, with the controller's replies back in the chat. /stop locks the kill switch |
| Workers on this computer | default (VOICE_GW_DOCKER empty) | Runs in your checkouts as you |
| Workers in Docker | VOICE_GW_DOCKER=<container> | Runs in the container; the level per repository comes from its GitHub token, or from VOICE_GW_ACCESS |
| Work folders | VOICE_GW_SCRATCH=1; off by default | Tasks without a repository run in a new empty folder each, on this computer as you (work folders) |
| Work folders in a container | also VOICE_GW_SCRATCH_DOCKER=<container> | Those runs in a container of their own that sees only the work folders, with no GitHub token |
4.5 Pushing from a read-only container
The container has no token that can write, so nothing leaves it by itself. You bring a branch out
at the desk, review it in a clean clone and push it from there.
scripts/fetch-from-container.sh alpha voice/fix-typo
git -C ~/Developer/alpha log -p origin/HEAD..container/voice/fix-typo
git -C ~/Developer/alpha push origin refs/remotes/container/voice/fix-typo:refs/heads/voice/fix-typo
What scripts/fetch-from-container.sh <repository> <branch> does:
- Checks the names and the clone. Plain repository and branch names only. There must be a
clean clone at
$VOICE_CLEAN_ROOT/<repository> (default: the folder that holds this
repository). It asks Docker which host folder is mounted at /repos and refuses a clean clone
inside it.
- Bundles in the container. `docker exec git -C /repos/ bundle create
- refs/heads/ ^<origin/HEAD>
: only the commits the clean clone does not have, when it knows origin/HEAD`; otherwise the whole branch. The bundle comes back on stdout into a
temporary file on this computer. It is not written into the mounted folder.
- Verifies and fetches. In the clean clone,
git bundle verify, then git -c transfer.fsckObjects=true fetch --no-tags --no-recurse-submodules --no-write-fetch-head <bundle> +refs/heads/<branch>:refs/remotes/container/<branch>.
- Hands over. Prints the new commits and the review and push commands. Nothing is checked
out and nothing is pushed: pushing is your step, with your own login.
Settings: VOICE_GW_DOCKER (container, voice-workers), VOICE_GW_DOCKER_REPOS (/repos),
VOICE_CLEAN_ROOT, VOICE_GW_DOCKER_BIN.
4.6 Pushing at levels pr and full
5. Security design
The guiding assumption: whoever can talk to the connector, and any text a worker reads, may be
hostile. Prompt injection through a repository, a pull request or a web page is the main risk, next
to a lost phone or a leaked token.
5.1 Who can do what
| Actor | Can | Cannot |
|---|
| Anyone on the internet | Reach the sign-in page; get a line in the log, and for anything suspicious a message on Telegram | Reach /mcp from outside Anthropic's addresses; call a tool without a token; get a token without a pairing code from your computer |
| A signed-in voice client | Send requests, read answers, answer approvals, lock the kill switch | Unlock the kill switch; reach the controller's tools directly |
The controller, level confined (default) | Read controller/, write controller/playground/, read pull request and CI status, merge after an approval, dispatch workers with your words from a recent voice request, raise the alarm | Start a worker on words that only a worker result or a pull request brought in (see 5.4); anything else (see 5.3) |
The controller at clone-delete | As at confined; also clone GitHub repositories into the worker root and delete files and folders there, each deletion after its plan, your words and an approval | Delete outside the worker root, the gateway's own checkout, or in a repository while its worker runs |
The controller at unrestricted | Everything your user can: any command, any file, .secrets/ included, the web, subagents; your MCP servers and connectors after an approval | Nothing is blocked; a merge in the strict form and a confirmed delete_path still ask (see 5.3) |
The controller with Claude in Chrome (only at unrestricted) | Everything the Chrome profile is signed in to: open sessions and logins on any site, and what a password manager in that profile fills in (the warning) | Nothing more is blocked by the gateway |
| Whoever is signed in to your claude.ai account, with Remote Control on | Follow the controller in the Claude app or at claude.ai/code, type to it, answer its approvals, stop its turn, switch it between Manual, Accept edits and Plan (Remote Control) | Start a worker, clone or delete by typing (see 5.4); lift the confinement; see or steer the workers' runs |
5.2 The voice endpoint and OAuth
- Binding and exposure. The gateway listens on
127.0.0.1 only (VOICE_GW_HOST). A tunnel or
reverse proxy publishes it at HTTPS on port 443. A certificate from a public authority, Tailscale
Funnel's included, puts the host name into the certificate transparency logs, and scanners find it
within hours.
/mcp only from Anthropic's addresses (src/ipguard.mjs). Claude voice mode calls the
connector from Anthropic's servers, which use the published range 160.79.104.0/21. A request to
/mcp that came through the tunnel from anywhere else gets a 403 before its token is looked at.
The address is the last X-Forwarded-For entry, the one the tunnel set; Tailscale Funnel also
marks its requests with Tailscale-Funnel-Request. Programs on this computer that call without
X-Forwarded-For, such as scripts/fake-voice.mjs, pass. Other ranges, or none:
VOICE_GW_MCP_ALLOW (8.8). The sign-in pages stay open to every address:
you sign in from your own browser.
- Every request in the log: one line with the caller's address,
funnel, proxy or local,
the method, the path without its query, and the status. Only the watchdog's local /healthz
checks are left out.
- Alerts on Telegram, if it is set up: a request to
/mcp from outside the range; one from
Anthropic's addresses without a valid token (you connecting the connector, or someone else's
Claude account that found the address); a sign-in without a valid pairing code, a wrong code, too
many wrong codes, a burnt code; and every successful sign-in, with the client's name. Each names
the address. An expired token of a known connection, the hourly refresh, is only logged.
Throttled: one message per kind and address in 15 minutes, six an hour in all; what is held back
is counted in the next message, or in a summary once messages may go out again. A successful
sign-in always goes out. Where Telegram's /funnel off works (Telegram set up, and
scripts/funnel.sh answers), the suspicious-activity alert about a request from outside the range
and the summary of more suspicious requests end with "Send /funnel off to take the gateway off the
internet."; /funnel off from your chat removes the Funnel, /funnel on brings it back.
- The gateway is its own OAuth server. OAuth metadata, dynamic client registration, client ID
metadata documents, and the authorisation code flow with PKCE (S256).
5.3 The controller's confinement
VOICE_GW_CONTROLLER_LEVEL sets how far the controller may go. The setup assistant offers it, with
a warning for the two levels above the default:
| Level | What the controller can do | Warning |
|---|
confined (default) | What the rest of this section describes: answer, dispatch workers, read pull request status, merge after an approval | – |
clone-delete | The same, plus clone_repo and delete_path (below) | Whoever can talk to the connector can have repositories cloned, and files and folders in the worker root deleted, whole repositories with work that exists nowhere else included. Whoever can call the connector also gives the approval for each deletion. A deletion cannot be undone |
unrestricted | Everything your user can: the hook lets every tool through, and the start script passes allow rules instead of deny rules. Tools of other MCP servers and claude.ai connectors still ask, by voice | Whoever can talk to the connector can run any command as you, read and change any file (.secrets/, SSH keys and logins included) and reach the web. Text that a worker result or a web page brings in can steer a session that can do everything, and the kill switch's rules watch only the workers. Only on a computer or VM you can afford to lose |
Claude in Chrome (VOICE_GW_CONTROLLER_CHROME=1) gives the controller a browser, and with it
whatever that browser profile is signed in to. Its tools pass the confine hook only at
unrestricted; below it, --chrome loads them and the hook blocks them. Read
the warning before turning it on.
Above confined, the start script also tells the session its level with
--append-system-prompt, since controller/CLAUDE.md describes the confined working area. The
level counts from the next start of scripts/start-controller.sh (--stop, then start again); an
unknown value counts as confined. At no level does the session run in bypass mode or
with --dangerously-skip-permissions.
Cloning and deleting (src/folders.mjs), at clone-delete and unrestricted, with workers
on this computer only:
clone_repo runs gh repo clone with your gh login, for owner/name or a name of your
own, into the worker root, in the background. The result arrives as a channel event, and you
hear it as news. A failed clone leaves nothing behind; the kill switch ends one that is going.
delete_path takes a path relative to the worker root. Without confirm it deletes nothing
and says what would be lost: files, size, uncommitted changes, commits on no remote, stashes.
With confirm it deletes only a path planned in the last 15 minutes, and the confine hook
answers "ask": Claude Code raises an approval that the channel relays. So, as for a merge, a
spoken yes and then the approval.
- Never: outside the worker root (the folder a path sits in is resolved through symlinks; a
link is deleted, not its target), the gateway's own checkout or a folder that holds it, a
repository while its worker runs or a clone is going.
- Both need your words from a recent voice request, as
dispatch does
(5.4), and do nothing while the kill switch is locked.
At the levels confined and clone-delete: the controller runs without approvals for its few tools, since whoever can reach it could answer
approvals anyway. Its limits are enforced by a PreToolUse hook, controller/.claude/hooks/confine.mjs.
A hook that exits with code 2 blocks a call before any permission rule or prompt. The hook sees
every tool call (matcher *) and blocks every tool that is not on its list. It resolves symlinks
and fails closed.
| Tool | Allowed |
|---|
| The gateway's tools | reply, list_workers, dispatch, worker_status, stop_worker, open_items, resolve_decision, stats, restart_controller, raise_alarm, and above confined clone_repo and delete_path (a confirmed one gets "ask"), as mcp__voice__… and mcp__plugin_voice_voice__… |
| ToolSearch, TodoWrite, TaskCreate, TaskGet, TaskList, TaskUpdate | Always. They load deferred tools and keep the to-do list |
| Read, Glob, Grep | Inside controller/ |
| Write, Edit, MultiEdit, NotebookEdit | Inside controller/playground/ |
| Bash | ls inside controller/; sleep <seconds>; rm [-f] with plain file names in playground/; the gh commands below |
gh, read-only | gh pr view, list, checks, diff, status; gh run view, list. Each needs --repo owner/name and only takes listed flags, for example --json with a plain field list, --state, --limit, --author, --branch, --log, --log-failed |
gh pr merge | gh pr merge <number> --repo owner/name with exactly one of --squash, --merge, --rebase, optionally --delete-branch. The hook answers "ask" |
| Everything else | Blocked, with a reason for the controller |
- Why deny by default. The controller keeps your user settings, because Claude Code needs them
for channels. So your plugins (with their skills and hooks), your allow rules and, in plugin
mode, your MCP servers load into it too. An MCP tool that your settings allow would otherwise
run unconfined and without a prompt.
- Blocked, among others: every other MCP server's tools, also those from plugins; subagents
(Agent, Task), since it is not clear that the hook sees their calls; skills; web fetch and
search; MCP resources;
AskUserQuestion and plan mode, since nobody can click a form in a
voice conversation.
- Only the gateway as MCP server, with the development flag. The start script passes
--strict-mcp-config --mcp-config controller/.mcp.json, so no other MCP server starts.
- Not with the plugin.
--strict-mcp-config also drops plugins' MCP servers, the gateway's
included (checked in Claude Code 2.1.284). In plugin mode your MCP servers start, and the hook
blocks their tools.
- No planted instructions. The file tools and
rm never touch files named CLAUDE.md,
CLAUDE.local.md, AGENTS.md or .mcp.json, or anything in a .claude folder, also in
playground/. Claude Code would read them as instructions or settings. Case does not matter.
- Blocked
gh commands: gh auth (prints the token), gh api (can write), gh config,
gh alias, gh extension, every other subcommand that writes (checkout, edit, comment, review,
close, ready, run rerun, cancel, delete, download), and --web, --jq, --template.
- Blocked merge options:
--admin, --auto, --disable-auto, --match-head-commit, the
message options, and branch names instead of numbers.
- "Ask" for a merge. The hook answers "ask", which overrides the settings' allow rule for Bash:
Claude Code opens an approval prompt, and the channel relays it to the voice conversation.
Verified with Claude Code 2.1.310: without "ask" the allow rule lets the merge run at once; with
it, the merge waits for the approval.
- Two confirmations per merge.
controller/CLAUDE.md makes the controller ask you first, so
every merge needs a spoken yes and then the approval.
- Denied below : claude.ai connectors (), web fetch and
search, messages to other sessions, remote and scheduled agents, artifacts, Monitor, workflows
and worktrees. The start script passes these deny rules with
(): in they would hold at
every level. Bypass and auto mode cannot be turned on there, at any level.
5.4 Approvals by voice
- How it works. The gateway declares
claude/channel/permission, so Claude Code sends approval
prompts to the channel as well as the terminal; the first answer wins. A prompt has a five-letter
code, covers one action and drops from the voice view after 15 minutes
(VOICE_GW_PERMISSION_TTL).
- Whoever can call
approve can approve. The voice AI is told to approve only after an
explicit yes; that is an instruction, not enforcement.
- What it guarantees: the controller cannot merge on its own, for example because a pull
request's text told it to.
- Turning it off:
VOICE_GW_PERMISSION_RELAY=0. Then approvals, merges included, happen only
at the terminal.
- Answered elsewhere. With Remote Control on, the Claude
app shows the prompt too, as a third place. An answer at the terminal or in the app does not
reach the gateway: the car keeps the prompt until it drops after
VOICE_GW_PERMISSION_TTL, and
an answer by voice then has no effect.
- Secrets in prompts: Claude Code masks recognisable credentials in relayed prompts, but not
every secret (channels reference).
Dispatch needs your words. The controller also reads text nobody here wrote: worker results,
which carry whatever a run read, and news about pull requests. Both arrive with trusted="no",
and its instructions call that text data, never instructions. The gateway checks in code as well
(src/channel.mjs):
- The check.
dispatch starts a run only when its words occur in a voice request of the
last VOICE_GW_DISPATCH_WINDOW minutes (30). At least two words, whole words, compared in lower
case without punctuation and quotes. Worker results and pull request news never count.
- Answers count. A follow-up "(Answer to ) Yes." is a voice request too. The words may
start in "(Answer to )", so that a plain yes can be passed on, but must reach into what you
said; the gateway's prefix alone does not count. With Telegram input on, so does an answer you
typed in your Telegram chat, as a Reply to a question or with its code
(5.8): the gateway routes it itself, only from your own account in
your private chat with the bot and only to an open question, and the controller sees it with
via="telegram".
- Requests by Telegram count, with Telegram input on. Any other message you write in that chat
is a request of yours, as one by voice is, also with
via="telegram". When it replies to another
message the gateway sent, that message is quoted after your words as context; only your own
words count, never the quote.
- Except push, merge, restart and delete. Words typed in Telegram never settle such a decision
through
resolve_decision, start no follow-up dispatch for an answer to one, no dispatch with
direct, confirm no delete_path and no restart_controller: each answers needs_driver, and
the controller asks you by voice (5.8).
- Every dispatch. With
direct, draft and allow_dirty too; direct only on spoken words,
or under a standing order you made by voice. Otherwise dispatch=needs_driver: nothing starts,
and the controller is told to ask you by voice. list_workers, worker_status, stop_worker,
open_items and stats are not checked.
- Cloning, deleting, restarting.
clone_repo, a confirmed delete_path and
restart_controller with confirm are checked the same way; the last two only on spoken words.
5.5 Workers: on this computer or in Docker
What applies to every worker run (src/workers.mjs):
- Options:
claude -p --permission-mode bypassPermissions --permission-prompts none --strict-mcp-config. A repository's own MCP servers do not start.
- Denied tools: claude.ai connectors, messages to other sessions, remote and scheduled agents,
artifacts, workflows, Monitor, worktrees,
AskUserQuestion, plan mode and the security-review
skill. That skill puts its own prompt in place of the task, so a run that called it near the end
stopped with the review's report, before its commit and push. The rules ask workers to finish
the job themselves (commit, push, pull request, without asking first) and to carry on after any
review, subagent or skill.
- Environment: the controller session's variables (
CLAUDECODE, its messaging socket and
token, the session id) are removed, so a worker cannot reach the controller.
- Before a run: the gateway refuses a dispatch without your words from a recent voice request
(5.4) and a checkout with uncommitted changes until you agree. A
second run in the same repository (more than
VOICE_GW_MAX_WORKERS_PER_REPO, one by default),
or one more than VOICE_GW_MAX_WORKERS runs in all, waits in the queue instead
(several runs per repository). A run stops after
VOICE_GW_WORKER_TIMEOUT minutes, six hours by default (0: no time limit).
- When a run has ended: when every process of it has, not when its output shows a result.
Claude Code can answer a turn of its own first, such as the notification of a background task
left over in the session it resumes, write a result for it and go on with the task in the same
process; a run can also leave a server or a test running. Every process of a run carries marks
in its environment, which whatever it starts inherits (
VOICE_GW_RUN, VOICE_GW_TASK,
VOICE_GW_REPO). The gateway finds them by these, on Linux in /proc and on macOS with
ps -E, besides the run's process group and the tree below it: also those that left the group
or whose parent ended. Until all of them are
gone, the run counts as running (state=ending once it is being stopped): the rules watch it,
the kill switch stops it, nothing else starts in its checkout, and its git check waits. When the
run's own process ends while processes it started still run, the gateway ends those; a run that
gave its result and stays without a word for two minutes, the gateway ends too. Stopping sends
SIGTERM to all of it, then SIGKILL every five seconds until it is gone, also from the next
gateway after a restart. A run's own process is recognised by its start time, so a later
process with the same id is never taken for it. Worker processes in a checkout that belong to no
run the gateway follows (its state was lost, for example) are ended when the gateway starts,
before a run there, and by the kill switch, and nothing starts in that checkout until they are
gone. These two, the start time and the processes of no run, need : Linux only.
| On this computer | In Docker |
|---|
| Runs as | You, in your checkouts in VOICE_GW_WORKER_ROOT | Your user id, in the container's clones in the mounted folder |
| Reaches | Everything your user can: files, SSH keys, the gh login, .secrets/, this repository's code | The mounted folder, /run/secrets/voice (read-only), the network |
| GitHub | Your gh login: every repository you can write to | The container's token, at the level it allows |
| Started by | spawn in its own process group, with the run's marks | docker exec … voice-run, which hands over the tokens, marks the run (VOICE_GW_TASK) and starts its own process group |
| Stopped by | Signals to its process group, the tree below it and every process with its marks | voice-stop: its process group and every process with its mark, in the container; also once its own process has ended |
| Isolation | None | The container |
The container (docker/Dockerfile, scripts/start-workers-container.sh):
- Image:
node:22-bookworm-slim with git, gh, procps and Claude Code. Its user worker has
your user and group id.
- Started with
--user <uid>:<gid> --init --restart unless-stopped.
- Mounts: the folder of clones at
/repos, read-write, or read-only while the kill switch is
locked; the volume voice-home at /home/worker; .secrets/worker at /run/secrets/voice,
read-only.
- Tokens:
voice-run reads them for each run (CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN). git
gets the GitHub token from the credential helper git-credential-voice. They are never part of
the container's configuration.
- Docker itself: whoever controls the gateway can control Docker, which on Linux is as good as
root.
Several runs per repository
By default a repository has one run at a time, and the rest of its tasks wait in the queue.
VOICE_GW_MAX_WORKERS_PER_REPO (1), or "Runs per repository" in the setup assistant, allows more.
VOICE_GW_MAX_WORKERS stays the limit over all of them: every run of a repository counts, so it
still protects your subscription.
- Where they run. The first run of a repository works in its checkout, as always. Every
further one works in a git worktree of its own, in
<VOICE_GW_WORKER_ROOT>/.voice-worktrees/<repository>/<task id>: outside every checkout, and
not listed as a repository because of the dot. In Docker the same, in the container's clones
under /repos/.voice-worktrees, every git command through docker exec.
- How a worktree is made. The gateway fetches the branch the checkout's run would start from
(
origin/<branch>, usually the default branch) and adds the worktree there, on a detached
HEAD. Submodules come from the checkout's own modules, a local clone that downloads nothing, also
for a shallow one such as drawio in traffic; only a commit the checkout's module lacks comes
from the submodule's URL, as one shallow commit. Where the checkout has not initialised a
submodule, it is a shallow clone from its URL. The URL goes with -c, so the checkout's config
stays as it was. A submodule that fails is named in the worker's prompt, and the run starts all
the same.
- Its own session and rules. A run in a worktree has a Claude session of its own, not the
repository's, and the same options, denied tools, environment, marks and time limit as any run.
For the kill switch's rule on writes, its worktree is its repository; the checkout is outside.
Its prompt says how it works there: create its
voice/ branch there and stay on it; before
pushing, git fetch origin, rebase onto the branch its pull request goes to (or the default
branch, where pull requests are off or with direct), run the tests, resolve conflicts itself
and run the tests again; push only when they pass, a branch of its own that was pushed before
with --force-with-lease; where pull requests are off, git push origin HEAD:<default branch>.
- Who waits for whom. The controller decides per task, without asking you:
parallel when
it needs none of the other tasks' results and works on other files, after with the ids of
runs or queued tasks it must wait for, or neither, which keeps it behind everything the
repository had when it came. dispatch=queued lists the runs and queued tasks there with their
texts; order_queue changes how a queued task waits later, without your words, as it changes
only the order, not what is done. Its confirmation says in a few words whether it runs in
parallel or what it waits for, and why. A task waits until a run slot and a slot in its
repository are free and what it waits for has ended.
- Follow-ups and answers. A follow-up for a run in a worktree goes there (
continues), and
your answer to a run's result goes there by itself: the same worktree and session. A worktree
removed meanwhile is made again at the same path, on the branch its run ended on, so that the
session continues.
| Run in the checkout | Run in a worktree |
|---|
| Folder | VOICE_GW_WORKER_ROOT/<repository> | VOICE_GW_WORKER_ROOT/.voice-worktrees/<repository>/<id> |
| Starts from | The branch the checkout is on | That branch as origin has it, fetched just now |
| Session | The repository's | Its own, kept for follow-ups there |
| At the end | Back on its starting branch | On its own branch; rebased, tested, pushed |
| Interrupted | Ends; its result says so | Back in the queue, in front, with its worktree |
Work folders
Off by default; VOICE_GW_SCRATCH=1, or the setup assistant, turns them on. A task that needs no
repository, such as a question to look up, research, a calculation, a draft or a conversion, then
gets a work folder: a new empty folder of its own (src/workspaces.mjs). Each run in one is a
worker run like any other, with the same options, denied tools and environment as above.
- Where. In
VOICE_GW_SCRATCH_ROOT (~/voice-scratch), as <date>-<id>, mode 0700. The
root must lie outside every git repository, this checkout and the repositories folder, and not be
the home folder itself; otherwise the gateway leaves work folders off and logs why
(work folders off: …).
- The tools. The controller uses the same ones:
dispatch with the worker scratch/new makes
a folder and answers with its name, scratch/<id>; follow-ups and answers go to that name and
continue in the same folder and Claude session. list_workers shows scratch/new and the last
used work folders; worker_status and stop_worker take their names. direct, draft and
allow_dirty have no effect there, and dispatch says so.
- Runs at a time. New ones take any free slot of
VOICE_GW_MAX_WORKERS and count against it,
as many as there are slots; in each folder one run at a time, whatever
VOICE_GW_MAX_WORKERS_PER_REPO says. The rest waits in the queue, as for repositories.
- The same safety. Only on your words from a recent voice request
(5.4); the kill switch stops their runs, empties their queue and makes no
new folder while locked; the queue, the folders and their sessions survive restarts in
.state/workers.json.
- Only that folder to write. The rule on writes outside (5.9) allows a
run in a work folder only its folder, the temporary folders and its own Claude Code memory folder:
not
VOICE_GW_KILL_ALLOW_PATHS. Its rules say so, and to name the files it made, push nothing and
change nothing elsewhere. So a run that writes to the home folder locks the switch at auto.
- After a run the gateway looks at the folder itself, whatever the worker wrote: the files it
made, changed and removed. There is no git, so there is no push decision. The result event
carries
files="2 files made, 1 changed" instead of git, and a line with the names and where
the folder is.
- Removed
VOICE_GW_SCRATCH_DAYS (60) days after its last run, with its session, unless a run
goes, a task waits or a decision is open there; with , never. Folders in the root that the gateway did not make
stay, and so does everything while work folders are turned off. A run that began before they
were turned off is still followed where it runs.
In a container of their own (VOICE_GW_SCRATCH_DOCKER, empty by default): for a protected
environment, their runs run in a container from the worker image instead of on this computer.
- Started with
scripts/start-workers-container.sh --scratch, which the setup assistant runs:
--user <uid>:<gid> --init --restart unless-stopped, the root at /scratch
(VOICE_GW_DOCKER_SCRATCH), read-write or read-only while the kill switch is locked, its own
volume voice-scratch-home at /home/worker, and of the secrets only
.secrets/worker/claude_token, as a file of its own: no GitHub token, no repositories. A new
token counts once the container starts again.
- Not the workers' container. The gateway and the script refuse its name; with workers in
Docker, the two are separate containers from the same image.
- Through
docker exec. The gateway makes a folder, lists it after a run and removes it in the
container, as it runs every git command there; on this computer it never walks or deletes what a
run could have laid out, such as a link to your home. Runs start and stop with voice-run and
voice-stop, as in the workers' container.
- The kill switch stops it and takes its restart policy; the gateway stops it again if it runs
with a writable folder while locked.
| Work folders on this computer | In their container |
|---|
| Runs as | You, in the work folder | Your user id, in the work folder at /scratch/<date>-<id> |
| Reaches | Everything your user can | The work folders, Claude Code's token, the network |
| GitHub | Your gh login, which its rules forbid it to use for anything but reading | Nothing beyond what anyone can read |
| Isolation | None; the rules only observe | The container |
5.6 Access levels and their detection
In Docker, each repository gets a level (src/access.mjs). list_workers and dispatch show it
with the reason, for example access pr (main is protected by a ruleset).
| Level | Token | Default branch | Workers |
|---|
read-only | None, or one that cannot push or open pull requests | Any | Commit on a local voice/ branch. Never push, never open a pull request (4.5) |
pr | Can push (Contents) and open pull requests | Protected by branch protection or a ruleset | Push a voice/ branch and open a pull request |
full | As pr | Not protected | A pull request by default; a push to the default branch only when you ask, through dispatch with direct |
How the level is detected, per repository:
- No token or no GitHub remote:
read-only.
- Can your account push? It asks
GET /repos/{owner}/{repo}; permissions.push false
means read-only.
- Can the token write? It sends two write requests without content,
POST …/git/refs and
POST …/pulls. GitHub answers 422 when the token may write and 403 or 404 when not; neither
creates anything. Either one failing means read-only. In a repository with pull requests
turned off (has_pull_requests false), only the first is asked.
- Is the default branch protected? It looks at branch protection (
GET …/branches/{branch})
and the rulesets (GET …/rules/branches/{branch}). Protection, or a ruleset of type
pull_request, update, required_status_checks or merge_queue, means pr; otherwise
full. Rulesets answering 403 or 404, for example on a private repository without GitHub Pro,
count as none.
- Whatever it cannot tell counts as the lower level.
Pull requests turned off. In Settings > General > Features, pull requests can be turned off for
a repository. There is nothing to open then, so a worker commits on the default branch and pushes
it there:
- In Docker this happens at
full, shown as access full (pull requests are turned off, main is not protected: direct push). With the default branch protected, nothing could be published, so
the repository is read-only.
- On this computer,
gh api repos/{owner}/{repo} tells; list_workers shows pull requests off: pushes to main.
dispatch answers with push=direct, and the kill switch's rule on the default branch
(5.9) lets such a run push there, as after an explicit direct.
- Public repositories still need your yes before the push. With pull requests limited to
collaborators (
collaborators_only), nothing changes: workers open pull requests as before.
The token is read at every check, and each result is kept for ten minutes (one minute after an
API error). VOICE_GW_ACCESS=read-only|pr|full sets one level for every repository instead. A
level is only as strict as the token: a worker forced to read-only with a write token could still
push; only its rules forbid it.
The repository is pinned. The level is detected for the GitHub repository that the clone's
origin names, and the kill switch watches that one (5.9). A worker in the
container can change the origin, so the gateway keeps the repository it saw first for each clone,
in .state/workers.json under origins ("no GitHub remote" counts too). If the origin later names
another one:
dispatch answers dispatch=origin and starts nothing, also with direct.
list_workers shows the origin changed in the container from acme/x to acme/y; check it at the desk.
- At the desk, set the origin back in the container:
docker exec voice-workers git -C /repos/x remote set-url origin <url>. If you changed it
yourself, stop the controller (scripts/start-controller.sh --stop), remove the clone's line
under origins, and start it again.
Creating tokens:
| For | GitHub token |
|---|
read-only, public repositories | None |
read-only, private repositories | Fine-grained: only the chosen repositories, Contents: Read-only. Not a classic token: its repo scope can also write |
pr and full | Fine-grained: Contents and Pull requests: Read and write; Workflows: Read and write only if workers should change .github/workflows. Or a classic token with repo (and workflow), for all your repositories |
pr additionally | A ruleset on the default branch that requires pull requests, with an empty bypass list, or branch protection with "Do not allow bypassing". On private repositories this needs GitHub Pro, Team or Enterprise |
An organisation may have to approve a fine-grained token first (personal access tokens).
5.7 The mounted folder and the clean clone
Rules:
- git on this computer never runs in the mounted folder. Not by hand, not through a
git-aware shell prompt, not through an editor that opens the folder. The gateway runs every git
command for the container through
docker exec.
- Changes come out only as a bundle into a clean clone outside the folder
(4.5). The helper refuses a clean clone inside the
folder.
- Review before you push or check out. A checkout applies the branch's
.gitattributes with
filters configured on this computer, such as git-lfs. Workflow files under .github/ run on
GitHub with the repository's secrets.
Why, checked with git 2.53:
- Fetching from the folder starts the container's side. A fetch from a local path, plain or
file://, starts git upload-pack in the source repository, which reads that repository's
config. A container-side uploadpack.hideRefs decided which branches the host could fetch.
- No command ran that way, but the surface is open. git ignores
uploadpack.packObjectsHook
from a repository's own config, upload-pack runs no hooks, and since 2.45.1 it refuses the lazy
fetch of a crafted partial clone (CVE-2024-32004). Still, the host opens a repository the other
side controls, and every config key that upload-pack honours widens that.
- No flag avoids it.
--no-local does not exist for git fetch. protocol.file.allow=never
blocks local paths and bundles alike.
git status in the folder ran a container-side core.fsmonitor command. That is rule 1.
- A bundle is data: a list of refs and a pack. Fetching it reads no config of the other side,
runs no hook and needs no repository there. Fetching it,
git log -p and git diff ran nothing.
A checkout did run a filter from .gitattributes (rule 3).
Threat model: anything in the container may be hostile; a worker controls every file in the
folder, .git included.
- What this protects: nothing from the folder runs on this computer, and nothing goes to
GitHub from this computer unless you push it.
- What it does not do: judge whether the changes are good.
- Above
read-only: at pr and full the container pushes by itself, within its token.
5.8 The Telegram fallback
- On only with both files:
.secrets/telegram_token and .secrets/telegram_chat_id.
- When: after
VOICE_GW_TELEGRAM_AFTER seconds (600) without a voice tool call, voice mode
counts as ended. Gaps of a few minutes between calls are normal while you talk, so the wait is
long.
- What: only what needs you. Questions and approval requests that voice mode has not picked
up then also go to the chat, each once. Results, news and interim
working updates never do;
they wait for the next voice call, apart from the controller's replies to an answer or a request
you gave in the chat (below). A question the controller settled in the meantime is not sent.
- Not waiting for that: the kill switch alarm goes at once, whether voice mode is on or not,
and so do a note when the gateway ends because another process holds its port, the alerts
about suspicious requests and sign-ins (5.2), the Funnel's
messages (7.3), the watchdog's around a restart
(8.2), and the statistics when the gateway shuts down
(8.5).
- In night mode (night mode) the fallback pauses: no questions, approvals or
news, only alarms (the kill switch, a standby, a wait for the usage limit, once each), and the
controller's replies to what you wrote in the chat yourself. The morning summary names what the
night left, instead of one message each.
- Inbound, only from you: your own account (
from.id), in your private chat with the bot, the
configured chat. In a private chat the chat id is your user id, so .secrets/telegram_chat_id
is all it needs; the log names it at every start. Groups, supergroups, channels, other senders,
bots and messages on behalf of a chat are ignored, without a word back; forwarded messages,
messages written through an inline bot, edits and messages without text are refused, with the
reason in the chat. Commands: the kill switch (, , or an
emergency phrase), the Funnel (, , , "keep alive";
), the statistics (, ;
) and night mode (, "good night" on; , "I'm back" off;
). The kill switch comes first, also in a forwarded or edited message:
a stop can only lock. With Telegram input on, also answers to questions and any other message
as a request (below); a Reply to a question is its answer, whatever it says, and with requests
on, a command stays a command as a Reply to any other message, such as news. Approvals cannot be
answered by Telegram.
How safe is Telegram as input? In short: as safe as your Telegram account, and the gateway
keeps what a stolen account could do small.
- Who can write as you: anyone signed in to your Telegram account. That is you on your phone
and on every other device or Telegram Web session you once signed in to, and whoever takes over
the account: a SIM swap that receives the login code, a stolen or leaked session, a device left
signed in. Telegram has no second factor by default. Turn on two-step verification (a cloud
password: Settings, Privacy and Security), and look through the active sessions now and then
(Settings, Devices) and end those you do not know.
- A leaked bot token lets someone read what you write to the bot, swallow your messages before
the gateway reads them (then the log says that another program takes the bot's updates, and
/stop would not reach the gateway), and send messages to you as the bot. It does not let them
write as you: the gateway takes only messages from your own account. A fake question from them
that you reply to reaches the controller without its text, as the gateway quotes only messages it
sent itself. If the token may have leaked, revoke it with @BotFather (/revoke) and enter the new
one in the setup.
- Not end-to-end encrypted. Bot chats go through Telegram's servers in a form Telegram can
read, unlike its secret chats. What you write there and what the gateway sends (questions,
summaries of results, alerts) is readable by Telegram. Leave Telegram out if that is not
acceptable.
- What the hardening covers: only your account in your private chat counts; groups, channels,
other senders and bots are ignored without an answer; forwarded, inline-bot and edited messages
and anything older than two minutes are refused; the kill switch comes first and Telegram never
approves or unlocks; nothing passes while the kill switch is locked or the gateway is inactive;
push, merge, restart, delete and approvals need your voice; the input log keeps every attempt.
- What it does not cover: someone who has your account can still ask for ordinary work, which
workers carry out as on your voice: read code, run tests, open pull requests. The kill switch's
rules and
/stop (also from your phone) remain the brake. A worker on this computer runs as your
user and could read .secrets/ like any of your files; only workers in Docker cannot.
- Recommendation: turn Telegram input on only with two-step verification on the account. Keep
push, merge, restart, delete and approvals with your voice: they cannot be undone, or go out to
others, and a stolen session should not be able to do them. This is how the gateway works when
input is on; there is no setting that lets them through.
5.9 The kill switch
One lock in the gateway's code (src/killswitch.mjs, src/guard.mjs). It does not depend on the
controller following instructions.
What locks it is a setting, VOICE_GW_KILLSWITCH, also in the setup assistant:
| Setting | Locks it | The rules |
|---|
auto, the default | Every trigger below | Lock it |
manual | Voice, Telegram and the controller | Only write to the gateway log what they would have done (kill switch not locked, VOICE_GW_KILLSWITCH=manual; rule would have locked it: …) |
off | Nothing; each trigger is only logged | Only log, as at manual |
At off, "emergency stop" by voice answers that the kill switch is off and hands nothing over,
/stop on Telegram gets the same answer there, and raise_alarm tells the controller to tell you;
workers can still be stopped one by one with stop_worker. The setting decides only what locks it
from now on: a lock from before stays until you unlock it, and the gateway's log names the setting
at every start.
Triggers:
| Trigger | How | Checked |
|---|
| Voice | "emergency stop" or "kill switch"; or the tool emergency_stop, which the voice AI calls for the same words in any language | In send and ask before anything reaches the controller: the message starts with the phrase, or has at most six words |
| Telegram | /stop or a phrase | See 5.8 |
| Rule: default branch | A push to the default branch, or a merge, without a direct dispatch at level full, in a repository with pull requests turned on | In the run's commands: git push to the default branch, --all, --mirror, gh pr merge, and gh api calls that merge (PUT …/pulls/<n>/merge, POST …/merges, the GraphQL mutations mergePullRequest, enablePullRequestAutoMerge, mergeBranch). With gh, every VOICE_GW_KILL_CHECK seconds and at the end: the default branch moved on GitHub, and the worker's repository holds the new head, or the new head merged a pull request of the run (below) |
| Rule: outside | Writes outside the repository (for a run in a worktree, outside its worktree), its own run folder (never another run's, nor .voice-runs around it, nor ~/voice-scratch), /tmp, /var/tmp (and the system's temp folder on this computer), the run's own Claude Code memory folder (~/.claude/projects/<repository path with - for every other character>/memory, where auto memory writes; another project's is outside) and VOICE_GW_KILL_ALLOW_PATHS; for a run in a work folder, writes outside that folder, those temporary folders and its memory folder; writing to /dev/null, the standard streams, a terminal or /dev/fd/N is none | The file tools by path. Shell commands by explicit targets: redirections, , , , , , , , , , , , , also after ; and are expanded, and so are and to the run folder. The command line is read as a shell reads it: quoted text, comments, here-strings and heredoc bodies are data, so a or in a pattern, a commit message or a script written to a file is none; the commands in , backticks, , , , and a heredoc a shell reads are checked like any other, and so is a script written from a heredoc that the same call runs |
Whose merge it is. A merge on GitHub makes a commit that the worker's repository does not
have. So when the default branch moved to such a commit, the gateway asks
gh api repos/{owner}/{repo}/commits/{sha}/pulls which pull request it merged. That merge counts
as the run's when:
- the run pushed the pull request's branch: named in a
git push, or logged in the worker's
repository as a push since the run started (the reflog of origin/<branch>); or
- the pull request was opened during the run, from a
voice/ branch.
A pull request that was there before the run, and that the run did not push to, is not the run's:
your merge at the desk of an earlier worker's pull request does not trip the switch. Your merge of
the running worker's own pull request does, so wait until the run has ended. GitHub cannot tell
who merged when you and the workers use the same account. Direct dispatches and repositories with
pull requests turned off are exempt, as for pushes.
Effects, at once:
- No new work:
dispatch answers locked, also a dispatch whose checks were still running
when the switch tripped; send and ask hand nothing over. The queue is emptied, and the
controller's alarm names its tasks: they do not start after the unlock by themselves.
- Approvals: open ones are denied, new ones are denied automatically.
- Workers: every run stops, with every process it started, also a run that is ending or
making its worktree, and so do worker processes that no run accounts for and a clone that is
going. A run in a worktree does not go back to the queue: its worktree is kept, as an open
decision that holds nothing. With Docker the container stops and loses its restart policy, and
so does the work folders' container.
While locked,
scripts/start-workers-container.sh mounts the folder read-only, and the gateway
stops a container with a writable /repos, or a work folders' container with a writable
folder, within VOICE_GW_KILL_CHECK seconds.
- Alarm: to Telegram at once, to every voice chat with its next call (
[ALARM]), and to the
controller.
- State: saved in
.secrets/killswitch.json, mode 0600; it survives restarts. A file that
cannot be read counts as locked.
Unlocking, only at the server's terminal:
node scripts/unlock.mjs --status # what locked it
node scripts/unlock.mjs # asks you to type "unlock"
scripts/start-workers-container.sh # Docker: start the container again
scripts/start-workers-container.sh --scratch # and the work folders' container, if you use one
It unlocks the running gateway through the admin socket, or removes the file. There is no way to
unlock by voice, Telegram or the controller.
5.10 Secrets and files
| Path | Content | Mode |
|---|
.secrets/ | Folder for everything below | 0700 |
.secrets/oauth.json | Clients and grants, hashes only | 0600 |
.secrets/admin.sock | Admin socket of the running gateway | socket in the 0700 folder |
.secrets/gateway.env | Settings from the setup assistant | 0600 |
.secrets/telegram_token, .secrets/telegram_chat_id | The Telegram bot, and your chat with it, whose id is your user id | 0600, tightened at every start |
.secrets/killswitch.json | Kill switch state, present while locked | 0600 |
.secrets/worker/claude_token, .secrets/worker/github_token | The container's logins, mounted read-only into it; into the work folders' container only claude_token | 0600, folder 0700 |
.state/ | Worker state, run output, open requests and decisions, watched pull requests, the watchdog's lock | 0700 when the gateway creates it |
.state/tasks/, .state/workers.json | Each run's output with every tool input and result; the workers' state, with the queue, the worktrees, the limit per repository the queue was planned for, and the work folders | 0600, folder 0700 |
VOICE_GW_WORKER_ROOT/.voice-worktrees/<repository>/<id> | The worktrees of further runs of a repository (several runs per repository), while something is left in them | As git makes them |
~/voice-scratch/<date>-<id> (VOICE_GW_SCRATCH_ROOT) | The work folders, with what their runs made | 0700, and the root too when the gateway or the start script makes it |
.state/requests.json | Voice requests and the answers, with what the voice client has read | 0600 |
.state/telegram-input.log, .state/telegram-input.log.1 |
Folders created by an earlier version keep their mode; tighten them once with
chmod 700 .state .logs.
.secrets/, .state/ and .logs/ are ignored by git. The controller's confinement keeps it
away from .secrets/; the container sees only .secrets/worker.
5.11 The setup assistant
It runs only while you set up, listens on every interface by default (for headless servers), and
is guarded as follows:
- One-time link: a token of 256 random bits, shown only on the terminal and compared in
constant time. With its output in a pipe or a file the assistant does not start, so the link
never lands in a log.
- One browser: the first use spends the token and binds the setup to that browser with an
HttpOnly, SameSite=Strict cookie.
- Wrong links: after five from one address in ten minutes, or 30 in total, every attempt is
refused for a while.
- Write requests must come from the page's own origin, as JSON. The page has a strict Content
Security Policy, sends no referrer and is not cached.
- It ends by itself: after 10 minutes without activity (
--idle), after 30 minutes at most
(--timeout), after writing, and on Cancel. With "keep this page open" ticked (the default) it
stays after writing, for connecting the Claude app, and ends on "End the assistant", after the
idle time, or at its limit, of which writing leaves at least 20 minutes (at most --timeout).
The page's polling of the gateway does not count as activity.
- Pairing codes only for that browser. Section 10 asks the running gateway for a code over its
admin socket only on a click, with the session cookie and from the page's own origin, so only
the browser that opened the one-time link gets one: as good as being at the terminal. The code
goes to that browser once, in the answer to its request; never into a URL, the terminal or a
log. The assistant keeps only its expiry and an id, to tell the page whether it still works or
was used, replaced or burnt. Valid ten minutes and one sign-in; a new one replaces the one
before in the gateway.
- Plain HTTP, by decision: a notice at the top of the page says to use it only on your own
network, since the one-time token, the keys and the pairing codes travel unencrypted. From
elsewhere, use an SSH tunnel.
- No secret leaves it. None goes back to the browser (saved ones show as
••••••••), to the
terminal or to a log; a pairing code goes only to the page that asked for it. Output of the
commands it runs shows a known secret as ‹secret›.
5.12 Limits
-
Nothing is undone. A push, a merged pull request or a deleted branch stays as it is. The kill
switch stops what comes next.
-
The rules see a step only after it ran. They read the commands Claude Code reports; the
command that breaks a rule has usually run already.
-
Hidden actions are missed. Not seen:
- a push or a write inside a script, an alias or another language; of a shell script that
cat
or tee writes from a heredoc, only what the same Bash call runs is seen;
- targets in variables other than
$HOME, and relative targets after a cd into one;
- writes by programs, such as
npm install into ~/.npm;
- paths behind symlinks.
A worker that fetched someone else's push can look guilty. A push whose commit the worker did
not keep is missed. A merge that you approved through the controller while a worker in the same
repository fetches can also look like the worker's push.
-
On this computer the kill switch is a tripwire, not a boundary. Host workers run as you,
without approvals. They can write .secrets/ (including killswitch.json and gateway.env),
use your SSH keys and gh login, and change this repository's code (the gateway, the confine
hook, controller/CLAUDE.md), which takes effect at the next restart; leaving this checkout out
of the workers (5.5) only keeps runs from starting
in it. A process that clears its environment and leaves both the run's process group and the
tree below it can survive a stop (the gateway reads the run's marks in the environment from
/proc on Linux and with ps -E on macOS). Only Docker isolates. The same holds for
work folders on this computer: their rule on writes is stricter, but a run there
can read and do everything your user can; in their container it reaches only the work folders.
-
Several runs in one repository share its git. Their worktrees share its objects and refs.
The rule on the default branch cannot tell whose commit a new head is while another run there
may push straight to it (direct at ), so it lets that go; and the count of changes at
the end leaves out the branches other runs pushed and their worktrees' commits only as far as
this gateway saw them. Whether two tasks touch the same files is the controller's judgement,
from their texts; a conflict is resolved by the run that pushes second, when it rebases.
6. Prerequisites
| Component | Version | Needed for | Notes |
|---|
| Node.js and npm | Node.js 20 or later (engines in package.json); tested with 22.22.1 and npm 10.9.4 | The gateway, scripts, tests | npm comes with Node.js |
| Claude Code | 2.1.80 or later for channels, 2.1.81 for the permission relay, 2.1.84 for allowedChannelPlugins (plugin mode). Tested with 2.1.310 | Controller and workers | Logged in with claude.ai. Channels need claude.ai or Console authentication; not on Amazon Bedrock, Google Cloud's Agent Platform or Microsoft Foundry (channels) |
| Claude subscription | See below | Voice mode, connector, channels, setup-token | |
| git | 2.29 or later on the host for scripts/fetch-from-container.sh (--no-write-fetch-head); tested with 2.53.0 | Workers, the fetch helper | The container brings its own (Debian bookworm) |
GitHub CLI gh | Tested with 2.46.0 | Host workers (push, pull requests), the controller's status questions and merges, the pull request watch, the kill switch's default-branch check | gh auth login on this computer |
| Docker | Tested with 29.8.0 | Optional: workers in a container | Docker installed as a snap cannot mount folders from /tmp |
| tmux | Tested with 3.6 | Optional: the controller in the background | The setup assistant's autostart uses it |
| systemd user services | – | Optional: autostart at boot on Linux | With lingering (loginctl enable-linger), which the setup assistant turns on; without it, only at a login and until the last logout |
| launchd (macOS) | – | Optional: autostart at login on macOS | A LaunchAgent, which the setup assistant writes and loads; at boot only with automatic login |
| bash | 3.2 or later | The start scripts | macOS's /bin/bash 3.2 will do; no GNU tools needed |
| A public HTTPS address on port 443 | – |
What npm install installs:
- Two direct dependencies, from
package.json:
@modelcontextprotocol/sdk ^1.32.2 (installed 1.32.2): the MCP server for both sides (stdio
and Streamable HTTP), and the clients in the tests;
zod ^4.6.7 (installed 4.6.7): schemas for the tool inputs.
- What the SDK brings:
@hono/node-server, ajv, ajv-formats, content-type, cors,
cross-spawn, eventsource, eventsource-parser, express, express-rate-limit, hono,
jose, json-schema-typed, pkce-challenge, raw-body, zod-to-json-schema, and their
dependencies.
- In total, 95 packages in
node_modules, pinned by package-lock.json. Nothing is compiled.
Operating systems:
- Tested: Ubuntu 26.04.1 LTS, for the gateway, workers on the host and in Docker, the
watchdog and the setup assistant. The controller's Bash allowlist exists because of Ubuntu
24.04+.
- macOS: supported, with Homebrew's
node, git, gh and tmux, the claude-code cask,
and Tailscale and Docker Desktop where wanted; install and test on a Mac. The
scripts run with macOS's own /bin/bash 3.2 and BSD tools; the test suite checks them with bash
3.2. What differs from Linux:
- Autostart: a LaunchAgent,
~/Library/LaunchAgents/com.drawio.cc-voice-gateway.plist, that
starts the controller in tmux at login (not at boot: with automatic login it comes back after a
restart). The setup assistant writes it with your PATH plus Homebrew's folders, checks it
with plutil and loads it with launchctl (7.2, 8.1).
- Sleep: while the watchdog runs,
caffeinate keeps the Mac from idle sleep
(VOICE_GW_CAFFEINATE=0 turns it off). A closed laptop lid still sends it to sleep.
- Paths: the managed settings in
/Library/Application Support/ClaudeCode/; the temporary
folders the kill switch allows include /private/tmp and /var/folders/…/T; the Tailscale
app's command line is found without "Install CLI".
- Written and checked on Linux, with bash 3.2 in Docker and the macOS code paths in the tests;
the first run on a Mac follows the checklist in docs/macos.md.
- Windows: through WSL 2. Follow the Linux instructions inside WSL (Ubuntu); the gateway,
tmux and the workers run there, and the autostart is a systemd user service if WSL runs systemd.
Nothing runs on Windows itself.
- Phone and car: the Claude app on the iPhone, in the car with CarPlay. Android and Android
Auto are not tested; nothing in the gateway is specific to either.
Claude subscription. The account that runs Claude Code and the account in the Claude app may
differ.
| Feature | Plans | Admin step on Team and Enterprise |
|---|
| Voice mode in the Claude app | All plans (voice mode) | – |
| Custom connector (remote MCP) | Free (one connector), Pro, Max, Team, Enterprise (connectors) | On Team, only Owners and Primary Owners add connectors |
| Claude Code channels | Pro and Max without an admin step (channels) | An Owner enables channels: Organization settings → Claude Code → Channels, or channelsEnabled: true |
| Remote Control for the controller, optional | Pro, Max, Team, Enterprise; not with an API key or a token from setup-token (Remote Control) | An Owner turns on Remote Control: Organization settings → Claude Code |
| Channel plugin without the prompt | Team and Enterprise managed setting allowedChannelPlugins (channels) | Owner, see 7.4 |
claude setup-token for the container | Pro, Max, Team or Enterprise (authentication) | – |
On a Team plan this means:
- an Owner enables channels and adds the custom connector;
- optionally, an Owner puts the plugin on the channel allowlist;
claude setup-token works.
A token from setup-token can only make model requests; it is used for the workers in the
container, not for the controller. It cannot open a Remote Control session either.
7. Installation
7.1 With the setup assistant
gh repo clone alderg/cc-voice-gateway && cd cc-voice-gateway
npm install
node scripts/setup.mjs
The assistant prints a one-time link per address of the computer. Open one in a browser on your
own network. It is a local setup over plain HTTP: the one-time token and the keys you enter travel
unencrypted. From anywhere else, use an SSH tunnel (ssh -L 8790:localhost:8790 <server>, then
the localhost link).
| Step | What the assistant does | What you give it |
|---|
| This computer | Checks Node.js, npm install, Claude Code (2.1.80+), git, the gh login, Docker, tmux, Tailscale | Fixes for what is marked as needed |
| Gateway | Port, the gateway's name for the voice assistant, whether it is in use or on standby, whether it switches off when its connector is disconnected (on by default; several gateways), repositories folder, runs at a time and per repository (both apply at once in a running gateway), time limit, pull requests ready for review or drafts, review comments fixed without asking (on by default) and how many automatic rounds per pull request (automatic fixes), approvals by voice, Remote Control for the controller (off by default), what the controller may do (with a warning above confined), what locks the kill switch (with a warning below auto), the Funnel off when idle and after how many minutes, the Funnel's port, 443 by default and never changed by itself (with a line about the connector for another port) | Choices |
| Models | Model and effort for the controller (--model, --effort from start-controller.sh) and for every worker run, on the host and in Docker. Claude in Chrome for the controller (off by default); turned on, a warning unless Claude in Chrome's notice for the first start is confirmed already (below) | Choices, or Claude Code's defaults |
| Telegram | Checks the token (getMe), finds your private chat with getUpdates once you wrote to the bot, sends a test message | A token from @BotFather, one message to the bot |
| Workers in Docker | Checks the GitHub token and shows the level per repository with the gateway's detection. Builds the image with its progress shown. Starts the container, sets git's name and email in it, and clones the chosen repositories inside it. While Docker does not answer (section 1), the checkbox is greyed out, with a line why and what it takes; one on from before stays as it is, and the line says that the container cannot run until Docker answers |
Claude in Chrome
[!WARNING]
A real security risk. While Chrome stays open for the controller, the controller can use
everything that browser profile is signed in to: active logins and sessions on any site, and
extensions such as password managers (1Password and the like) that fill in credentials. Anyone
who can steer the controller can reach that too: by voice, through Remote Control in the Claude
app, and at the level unrestricted, where the browser tools pass the confine hook, with
everything else your user can do. Take care. Use a separate Chrome profile only for Claude, or a
separate user of the computer, with the Claude extension and nothing else: no password manager,
no private or work logins, signed in only to what the controller is meant to use; close or sign
out of what it no longer needs.
What it needs while the gateway runs. Claude in Chrome works only while someone is signed in
at the desktop of the user the controller runs as, or in a remote desktop session that stays when
you disconnect, with Chrome open and the Claude extension signed in to the same claude.ai account.
Without that, the controller's browser tools answer that the extension is not connected, and the
controller tells you so in a sentence. Keep a Chrome window of your own open, not only the window
Claude opens for its tab group: if Claude Code closes its last window or tab and no other Chrome
window is open, Chrome quits entirely, and the browser tools stop working until someone starts it
again at the desktop. The controller's instructions therefore never close the last tab of its tab
group nor a window (controller/CLAUDE.md). At each start with Chrome on, the log says whether a
desktop session of the user and a Chrome run (watchdog: chrome desktop: …). Workers get no Chrome: they
run headless (claude -p) without --chrome, with --strict-mcp-config, so no rule is needed for
them.
Its notice for the first start. At its first start with Claude in Chrome, Claude Code shows a notice about it
and waits for Enter at the terminal, so a restart by the watchdog or the autostart can hang there.
The start script marks it as seen before each start (8.1), but cannot
always. With Claude in Chrome on, the assistant therefore shows a warning, and repeats it when it
writes the setup: before relying on unattended restarts, start Claude Code once at the terminal, as
the user the controller runs as, with claude --chrome, confirm the Claude in Chrome notice with
Enter and leave with /exit. If Claude Code's config (~/.claude.json, or
$CLAUDE_CONFIG_DIR/.claude.json) says that the notice is confirmed already
(hasCompletedClaudeInChromeOnboarding), it says so instead; it only reads that file. If it cannot
tell, it warns. Next to the option, the page also says what Claude in Chrome needs while the gateway
runs, and points to the warning above in one line; the finish log repeats both.
Values equal to their default are left out of .secrets/gateway.env, and other settings already in
it stay. Unticking Telegram deletes its two files. Options: --host (0.0.0.0), --port (8790),
--timeout (minutes, 30), --idle (minutes, 10), or VOICE_SETUP_HOST, VOICE_SETUP_PORT,
VOICE_SETUP_TIMEOUT, VOICE_SETUP_IDLE. Run it again to change something.
Then go on with Getting started from step 4, with the page's section 10 while
it is open: start the controller, publish the endpoint, add the connector and pair.
7.2 By hand
Everything the assistant does can be done with files and commands.
Settings: .secrets/gateway.env, one VOICE_GW_NAME=value per line, mode 0600. The values
are in 8.8. The gateway (src/settings.mjs) and the shell scripts
(scripts/settings.sh) read it line by line and never execute it. Variables set in the
environment or in controller/.mcp.json win. The watchdog reads it again before every start of the
session, so a restart, also one by voice, takes a change; only the controller level waits for the
next start of the start script (8.2).
mkdir -p -m 700 .secrets
(umask 077; printf 'VOICE_GW_WORKER_MODEL=opus\n' >> .secrets/gateway.env)
Telegram:
(umask 077; read -rsp 'Bot token: ' t; printf '%s' "$t" > .secrets/telegram_token)
# write to the bot from your Telegram account, then:
# printf is built into the shell, so the token stays off every command line (ps)
printf 'url = "https://api.telegram.org/bot%s/getUpdates"\n' "$(cat .secrets/telegram_token)" \
| curl -s -K - | jq '.result[].message.chat | {id, type, first_name}'
(umask 077; printf '%s' '<id of your private chat>' > .secrets/telegram_chat_id)
Take the id of the chat of type private. A webhook on the bot makes getUpdates answer
409 Conflict; another program polling the same bot takes the updates away.
Docker:
folder="$HOME/docker-developer"; mkdir -p "$folder"
docker build -t cc-voice-worker --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" docker/
mkdir -p -m 700 .secrets/worker
(umask 077; read -rsp 'Claude token: ' t; printf '%s' "$t" > .secrets/worker/claude_token)
(umask 077; read -rsp 'GitHub token: ' t; printf '%s' "$t" > .secrets/worker/github_token)
VOICE_GW_DOCKER_FOLDER="$folder" scripts/start-workers-container.sh
docker exec voice-workers git clone https://github.com/<owner>/<repo>.git /repos/<repo>
docker exec voice-workers git config --global user.name "Your Name"
docker exec voice-workers git config --global user.email "[email protected]"
(umask 077; echo 'VOICE_GW_DOCKER=voice-workers' >> .secrets/gateway.env)
- The Claude token comes from
claude setup-token on this computer.
- The GitHub token is optional (5.6).
- Clones use
https:// addresses, since the container has no SSH keys.
--build-arg CLAUDE_CODE_VERSION=<version> pins Claude Code; rebuild to update it.
- Clean clones for the fetch helper (4.5) are
ordinary clones from GitHub outside the folder. Your checkouts in the worker root will do: the
helper only adds refs to them.
Autostart on Linux: a systemd user service that runs
tmux new-session -d -s voice <repo>/scripts/start-controller.sh (Type=forking, with your
PATH), then systemctl --user enable cc-voice-gateway.service and loginctl enable-linger, so
that it starts at boot and keeps running without a login.
Autostart on macOS: a LaunchAgent, ~/Library/LaunchAgents/com.drawio.cc-voice-gateway.plist,
whose ProgramArguments are the same tmux command with tmux's full path (launchd does not search
the PATH), with EnvironmentVariables PATH (yours plus /opt/homebrew/bin, /usr/local/bin,
~/.local/bin and Node's folder), WorkingDirectory the repository, RunAtLoad true, KeepAlive
false (tmux returns at once; the watchdog restarts), AbandonProcessGroup true, and
StandardOutPath/StandardErrorPath ~/Library/Logs/cc-voice-gateway.log. Then:
plutil -lint ~/Library/LaunchAgents/com.drawio.cc-voice-gateway.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.drawio.cc-voice-gateway.plist # starts it now, and at every login
Then go on with Getting started from step 4.
7.3 Publishing the endpoint
Claude's servers call the connector from Anthropic's cloud. Any tunnel or reverse proxy works that:
- serves HTTPS on port 443 with a valid certificate (Tailscale Funnel serves 8443 publicly too,
but claude.ai's connector did not connect to it in our tests);
- forwards every path to
127.0.0.1:8787, not only /mcp: the sign-in uses
/.well-known/… and /oauth/…. Do not open port 8787 itself;
- passes the public host name in
Host or X-Forwarded-Host, and sends
X-Forwarded-Proto: https: the sign-in metadata is built from them;
- adds the caller's address as the last
X-Forwarded-For entry: only Anthropic's addresses
may call /mcp (5.2). A tunnel that cannot do that needs
VOICE_GW_MCP_ALLOW=any;
- allows responses of up to a minute, since
get_reply waits up to 40 seconds.
Tailscale Funnel (tested). On Linux run sudo tailscale set --operator=$USER once. On macOS
the script finds the Tailscale app's command line by itself; VOICE_GW_TAILSCALE_BIN names
another.
scripts/funnel.sh start # prints https://<host>.ts.net/mcp; reports when all relays answer
scripts/funnel.sh stop
scripts/funnel.sh state # on or off
scripts/funnel.sh url
The first start fetches a certificate; the name and all relays can take a few minutes. The port is
VOICE_GW_FUNNEL_PORT from .secrets/gateway.env: 443 by default, or 8443 or 10000, the only
other ones Funnel allows; FUNNEL_PORT overrides it for one call. With another port the URL ends
in :<port>/mcp, and the gateway builds its sign-in metadata with that port by itself, but
claude.ai's connector did not connect to the Funnel on 8443 in our test: keep 443 for it. Neither
the setup assistant nor the script changes the port by itself. Another web server on port 443 of
the computer: known issues.
The Funnel on demand (src/funnel.mjs), so that the endpoint is public only while you use it:
- From Telegram (5.8):
/funnel on or "keep alive" turns it
on, /funnel off off, /funnel says whether it is on. Every command gets an answer; a start
gets a second one once the public relays answer, usually within two minutes. So send it before
you start driving, not with the first voice request.
- Off by itself, with
VOICE_GW_FUNNEL_IDLE minutes set (the setup assistant offers it,
90 suggested; off by default). That long after the last voice call, or after the last
/funnel on, "keep alive" or gateway start, the gateway turns the Funnel off and says on
Telegram how to turn it on again. Without Telegram, it comes back on only at the computer.
- The gateway runs
scripts/funnel.sh, so the operator setting above is needed for this too.
Reverse proxy, for example Caddy, which gets a certificate and sets the headers by default:
voice.example.com {
reverse_proxy 127.0.0.1:8787
}
With nginx add proxy_set_header Host $host;, proxy_set_header X-Forwarded-Proto https; and
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;.
7.4 Admin steps for Team and Enterprise
On claude.ai, as Owner or Primary Owner:
-
Enable channels: Organization settings → Claude Code → Channels, or channelsEnabled: true
in the managed settings (channels).
-
Custom connector: on Team, only Owners and Primary Owners add it (connectors).
-
Optional, Remote Control for the controller
(Remote Control): Organization settings → Claude Code →
Remote Control, on; it is off by default there. On Enterprise with custom roles, your roles also
need the cloud sessions and the Claude Code remote control permission (Remote Control).
-
Optional, the channel without the prompt. Open
Organization settings → Claude Code → Managed settings,
keep what is there, and add:
{
"channelsEnabled": true,
"allowedChannelPlugins": [
{ "marketplace": "cc-voice-gateway", "plugin": "voice" }
]
}
Before saving: this list replaces Anthropic's default list of channel plugins for the whole
organisation. Telegram, Discord, iMessage and fakechat from claude-plugins-official stop
working as channels for every member unless the list names them too, for example
{ "marketplace": "claude-plugins-official", "plugin": "telegram" }. An entry matches by name:
whoever registers a marketplace called cc-voice-gateway with a plugin voice can load it as a
channel, and still has to opt in per session with --channels.
Claude Code picks the setting up at its next start, or within an hour; claude doctor shows the
Managed settings (remote) line. Nothing is left to do on the server. The start script reads
Claude Code's local copy of these settings, which Claude Code refreshes when it starts, so the
first start after saving may still use the development flag; the one after it uses the plugin.
8. Operation
[!TIP]
If anything stops working: run scripts/restart-everything.sh. Nothing to check first,
no questions: it stops everything of this gateway, ends what is left of it, starts it again
and waits until the gateway answers, saying each step in a line
(details).
8.1 Starting and stopping
tmux new -s voice scripts/start-controller.sh # start, with the watchdog
scripts/start-controller.sh --status # running? The watchdog, the session, the gateway
scripts/start-controller.sh --stop # stop session and watchdog
VOICE_GW_WATCHDOG=0 scripts/start-controller.sh # without the watchdog
--status answers in two lines, the first as it always did:
start-controller: running (watchdog 1489647, session 1489724)
start-controller: gateway process 1493320 (src/plugin.mjs), running for 1 h 48 min; port 8787 answers
The second names the gateway's process, src/plugin.mjs with the plugin and src/gateway.mjs
with the development flag, how long it runs, and whether its port (VOICE_GW_PORT, 8787)
answers /healthz. It counts only processes of this checkout, by the resolved path of their
script, never those of another checkout or worktree. A gateway whose session is gone shows as "of
no session of the watchdog", also after "not running"; "no gateway process of this checkout"
says that none runs, and "port … answers" then means that something else answers there.
With the autostart, the service or LaunchAgent only starts tmux; --stop ends the controller in
either case.
| Linux (systemd user service) | macOS (LaunchAgent) |
|---|
| Start | systemctl --user start cc-voice-gateway | launchctl kickstart gui/$(id -u)/com.drawio.cc-voice-gateway |
| Stop | systemctl --user stop cc-voice-gateway | scripts/start-controller.sh --stop |
| Restart | systemctl --user restart cc-voice-gateway | --stop, then kickstart |
| Status | systemctl --user status cc-voice-gateway | launchctl print gui/$(id -u)/com.drawio.cc-voice-gateway |
| On at boot or login | systemctl --user enable cc-voice-gateway | launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.drawio.cc-voice-gateway.plist |
| Off | systemctl --user disable cc-voice-gateway | launchctl bootout gui/$(id -u)/com.drawio.cc-voice-gateway |
| Its own output | journalctl --user -u cc-voice-gateway | ~/Library/Logs/cc-voice-gateway.log |
What the start script does:
- Folder and mode: starts
claude in controller/ with --permission-mode manual.
- Model and effort:
--model/--effort from VOICE_GW_CONTROLLER_MODEL and
VOICE_GW_CONTROLLER_EFFORT, --chrome with VOICE_GW_CONTROLLER_CHROME=1, and
--remote-control "Voice controller" with VOICE_GW_REMOTE_CONTROL=1
(below); options given to the script come last and win.
- The folder trust question: at the first start in a folder, Claude Code asks "Quick safety
check: Is this a project you created or one you trust?", with "No, exit" chosen, and waits at
the terminal. Before every start,
scripts/folder-trust.mjs therefore marks controller/ as
trusted where Claude Code notes the answer: projects["<folder>"].hasTrustDialogAccepted in
~/.claude.json ($CLAUDE_CONFIG_DIR/.claude.json if set), on the exact folder as the system
names it, also where a parent folder is trusted already. Only that key of that project, only when
it is missing, under Claude Code's own lock, the new file in place of the old in one step; a file
that does not parse is left alone. So it holds after the folder moved or the config was reset
too. The setup assistant does the same right before it starts the controller. If it cannot (no
such file yet, not valid JSON, locked for 5 s), the log line watchdog: trust: … says that the
start may ask at the terminal.
- Claude in Chrome's notice for the first start: at the first interactive start with
--chrome, Claude Code shows a notice about Claude in Chrome before the session starts ("Enter to
confirm · Esc to cancel") and waits for a key, so a start by the watchdog or the autostart would
hang there. Before every start with Chrome on, scripts/chrome-onboarding.mjs therefore marks it
as seen: hasCompletedClaudeInChromeOnboarding in Claude Code's ~/.claude.json
($CLAUDE_CONFIG_DIR/.claude.json if set), the key Claude Code sets itself once it has shown the
notice. Only that key, only when it is missing, under Claude Code's own lock on the file. If it
cannot (no such file yet, not valid JSON, locked for 5 s), the log line watchdog: chrome: … says
that the start may wait for Enter at the terminal, and so does the next restart's Telegram message
(8.2). A second line says whether a desktop session of the user and a Chrome
run, which Claude in Chrome needs (); the log only.
Claude Code starts the gateway itself, from controller/.mcp.json or as the plugin's server, so
the gateway lives and ends with the session. Do not drop the user settings with
--setting-sources: Claude Code then reports channels as not enabled for the organisation. Your
plugins, hooks and allow rules therefore load into the controller; the confine hook blocks every
tool that is not on its list (confinement).
Restarting everything
scripts/restart-everything.sh is for when something no longer works and you do not want to find
out what first. It asks nothing, and works the same with the autostart and without it:
- What runs.
--status, then each worker run going and what the restart does to it, and the
queued tasks, which stay queued.
- "A restart was requested" on Telegram, as for every restart on purpose; the stopping
watchdog then sends the shutdown message with the statistics, and the next gateway the ready
message (8.2). No other messages.
- Down. The systemd user service (
systemctl --user stop), or on macOS, where the LaunchAgent
has no stop of its own, and in tmux, the watchdog with start-controller.sh --stop.
- What is left, of this checkout only: the watchdog and the controller session that its lock
names, gateway processes of this checkout (by the resolved path of
src/plugin.mjs or
src/gateway.mjs), each with SIGTERM and after five seconds SIGKILL, and a lock no watchdog
holds. A process on the gateway's port that is not this gateway is named, not touched. Other
Claude Code sessions, other projects and whatever it cannot attribute to this checkout stay as
they are.
- The containers, with workers in Docker or work folders in a container of their own: started
with
scripts/start-workers-container.sh only when one does not run, and not while the kill
switch is locked. A running one is left as it is.
- Up. The service, the LaunchAgent (
launchctl kickstart, or bootstrap when it is not
loaded), or a new tmux session voice (a new window in it, if a session of that name stays).
- Until it runs:
--status once a second, until the watchdog runs and the gateway's port
answers, at most VOICE_GW_RESTART_WAIT seconds (120). It ends with that status, or with what
still fails and where to look: journalctl --user -u cc-voice-gateway, the LaunchAgent's log,
tmux attach -t voice, the gateway's log; with the development flag, the warning waits for
Enter at the terminal.
Worker runs. On Linux the service's stop ends the runs on this computer with it, as they are
its processes (KillMode control-group, systemd's default), as every systemctl --user restart
does. After the start the gateway sends a run in a worktree back to the queue, where it resumes,
and reports a run in a repository's checkout as failed; that one is not queued again. Under tmux
and the LaunchAgent, and in a container, runs keep going, and the new gateway follows them. Run
from inside the service, a window of its tmux session for example, the script first moves itself
out with systemd-run --user --scope, or the stop would end it too. Its lines go to the gateway's
log as well, as restart-everything:.
Remote Control from the Claude app
Off by default; VOICE_GW_REMOTE_CONTROL=1, or the setup assistant, turns it on, from the next
start of the session (a restart by voice will do). The start script then passes
--remote-control "Voice controller" (Remote Control). At claude.ai/code or in the Claude
app's Code tab, on an iPad for example, the session "Voice controller" shows what reaches the
controller and what it does: the voice requests, its answers, the worker results.
| Session | Remote Control | Why |
|---|
| Controller | Yes | An interactive session, signed in with claude.ai |
| Workers on this computer, work folders | No | Headless claude -p runs. --remote-control and the setting remoteControlAtStartup are for interactive sessions; in a -p run Claude Code ignores the flag without a word (checked with 2.1.310) |
| Workers in Docker, the work folders' container | No | Headless too, and the token from claude setup-token only makes model requests: it cannot open a Remote Control session (authentication) |
The gateway's log says the same at its start. From the app, on the controller:
- Type to it. A typed message reaches the session as your turn, not through the channel: no
request_id, nothing read aloud. The controller answers it in the app (controller/CLAUDE.md).
- No worker by typing. Typed words are no voice request, so
dispatch answers needs_driver
(5.4), as do clone_repo, a confirmed delete_path and
resolve_decision. Say it by voice, or with Telegram input on write it in your Telegram chat:
a request or an answer there is different, as the gateway routes it itself, from your own
private chat with the bot (5.8). Status questions, list_workers, worker_status,
open_items, stop_worker and a merge (your yes, then the approval) work.
- Approvals show in the terminal, in the car and in the app; the first answer counts
(5.4).
- Stop, mode, commands. Stop ends the current turn, as Esc does. The mode picker offers
Manual, Accept edits and Plan; leave it at Manual. Bypass and auto are turned off in
controller/.claude/settings.json. /exit from the app ends the session as at the terminal,
and the watchdog takes that as quitting: it does not start it again.
- The confinement holds. The confine hook sees every tool call, whoever asked for it, in every
mode (5.3). The workers and the kill switch's rules are not
touched.
- Each start is a new session in the app; after a restart the one before shows as offline.
It needs Claude Code on this computer signed in with claude.ai, not an API key, apiKeyHelper or
ANTHROPIC_AUTH_TOKEN; Anthropic's API, not Amazon Bedrock, Google Cloud's Agent Platform,
Microsoft Foundry or another ANTHROPIC_BASE_URL; and neither
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC nor DISABLE_GROWTHBOOK set. On Team and Enterprise
an Owner turns it on (7.4). Without that, the session starts all the same,
with a notice in its terminal; claude doctor names the check that failed. If Claude Code asks
once whether to enable Remote Control, answer at the terminal (tmux attach -t voice).
Claude Code's documentation names no conflict between Remote Control and channels; the
combination has not been tried with a live session here yet. Before relying on it in the car, try
a voice request and an approval with the app open.
Security.
- Your claude.ai account steers the controller. Whoever is signed in to it, on any device, can
read the session and act in it, with your approvals: merge at
confined, delete at
clone-delete, anything at unrestricted. Protect the account; "Require trusted devices" ties
Remote Control to devices you enrolled (Remote Control).
- The transcript is stored at Anthropic while connected, voice requests, worker results and
tool output included, to keep the devices in sync (Remote Control).
- Typed is not spoken. The dispatch check holds in code. The controller takes a typed message
as yours, as it should; what it does without the check (stopping a worker, a restart, a merge
with your approval) a typed message can ask for.
- Your sessions on other machines reach it too. Over the same connection they can send it
messages (cross-session messaging), as sessions on this computer always could; its
instructions treat them as data.
8.2 The watchdog
| Behaviour | Default | Setting |
|---|
| Exit code 0 or 130 (you quit, Ctrl+C) ends the watchdog; any other code or a signal is a crash | – | – |
Health check of the gateway's /healthz | every 30 s | VOICE_GW_WATCHDOG_INTERVAL |
| Failed checks in a row (after the gateway answered once) before a restart | 3 | VOICE_GW_WATCHDOG_HEALTH_FAILS |
| Pause before a restart, doubled after each quick crash | 5 s, up to 300 s | VOICE_GW_WATCHDOG_BACKOFF, VOICE_GW_WATCHDOG_BACKOFF_MAX |
| A run this long starts the pause over | 600 s | VOICE_GW_WATCHDOG_STABLE |
| Gives up after this many restarts within the window | 5 in 1800 s | VOICE_GW_WATCHDOG_MAX_RESTARTS, VOICE_GW_WATCHDOG_WINDOW |
| One watchdog at a time | lock in .state/controller.lock | – |
Settings read again before every start, so a restart takes a change; the controller level, VOICE_GW_SECRETS_DIR, VOICE_GW_STATE_DIR, VOICE_GW_LOG_FILE and the watchdog's own settings stay until the start script starts again | – | .secrets/gateway.env |
| Log | watchdog: lines in .logs/gateway.log: who started and who stopped it (the autostart's service or LaunchAgent, or user, process and terminal), and each start's settings with where they came from | – |
| Telegram around a restart, a stop and a crash, and when it gives up; the gateway sends another when the controller is ready again (below) | if the two Telegram files exist | – |
macOS: no idle sleep while it runs (caffeinate -i -s, ended with the watchdog) | on | VOICE_GW_CAFFEINATE |
Telegram. A restart on purpose comes in three messages: that it was requested, that the
gateway is shutting down, with the statistics of its running time (8.5), and that
the controller is ready again. A crash keeps its single message, then the ready one:
| What | 1. It begins | 2. The gateway shuts down | 3. Ready again (from the next gateway) |
|---|
| A crash, or a gateway that stopped answering | "the controller session crashed (exit code 1) and starts again in 5 s, without a confirmation. A message follows when it is ready." | – | "the controller is ready again, after a crash (exit code 1)." |
By voice, restart_controller (8.3) | "a restart was requested by voice; the controller restarts now. A message follows when it is ready." | "the gateway is shutting down now for the restart. Its statistics: …" | "… after a restart requested by voice." |
The autostart's service, systemctl --user restart cc-voice-gateway | "a restart of the service cc-voice-gateway.service was requested; the controller restarts now. …" | the same | "… after a restart of the service cc-voice-gateway.service." |
scripts/restart-everything.sh (8.1) | "a restart of everything (scripts/restart-everything.sh) was requested; the controller restarts now. …" | the same | "… after a restart of everything (scripts/restart-everything.sh)." |
A stop: --stop, systemctl --user stop, quitting the session | – | "the gateway is shutting down now (stopped at the computer). Its statistics: …", or "(the service cc-voice-gateway.service was stopped)", "(the controller session was ended at the terminal)" | – |
Each starts with "cc-voice-gateway on :". The second comes from the watchdog, from the file
the gateway saves as it ends (scripts/stats.mjs), and only for the gateway of the session that
ended: one that never started, or never saved, sends none. A plain start or a boot sends nothing.
The service's restart runs start-controller.sh --stop as systemd's ExecStop with the job
restart (systemctl --user list-jobs): that --stop sends the first message and leaves the
reason in .state/controller.restarting, where the stopping watchdog finds it for the second and
the watchdog systemd starts next for the third. The LaunchAgent on macOS has no restart of its own:
--stop, then kickstart, is a stop and a start.
With the development flag, a restarted session waits for the channel warning to be answered at the
terminal (tmux attach -t voice, Enter); with the plugin it does not. With a personal account there
is no plugin, so every restart waits for it (known issues). Claude in Chrome's notice for
the first start would wait there too, at a start by the watchdog or the autostart alike; the start
script marks it as seen beforehand (8.1). The message when a restart
begins names what it may wait for: "Confirm the development channel at the terminal (tmux attach -t
voice), otherwise voice requests do not arrive." instead of "without a confirmation", and, when the
last start could not mark Claude in Chrome's notice as seen, "Claude in Chrome's notice for the first
start may wait for Enter at the terminal (tmux attach -t voice)."
8.3 Restarting the controller by voice
"Restart the controller":
- The controller explains and asks. It asks
restart_controller what a restart would do and
tells you, then asks: which worker runs are going, what happens to open requests, whether
someone has to confirm at the terminal.
- Only after your yes it calls
restart_controller with confirm and your words, which the
gateway checks as for dispatch: spoken, never typed in Telegram.
- The restart. The gateway leaves a note for the watchdog and ends the session. The watchdog
says on Telegram that a restart was requested ("a restart was requested by voice; the
controller restarts now"), then that the gateway is shutting down, with the statistics of its
running time (8.5), starts it again at once, without a pause, and does not
count it as a crash.
- Ready message. A Telegram message says when the controller is ready again, if Telegram is
set up (8.2).
What survives:
- Worker runs: detached; the new gateway follows them and reports their results.
- Queued tasks: in
.state/workers.json; the new gateway starts them in their order.
- Open decisions: in
.state/jobs.json, with your answers.
- Requests: kept in
.state/requests.json. Answers given before the restart stay deliverable.
An unfinished request ends with a note to say it again; a question to you stays open, and you
can still answer it. A worker result the old controller had not answered goes to the new one
again.
- What is open, for the new session: once it is connected, the gateway sends it one event with
kind="summary": open decisions, queued tasks, runs going, requests not finished, and the
requests the restart ended. The controller works from it instead of memory files. Its reply to
it is for the gateway only: no news, no Telegram.
- Not: open approvals, and the old session's conversation.
Without the watchdog the controller says it cannot restart itself.
8.4 Voice commands
Start with "Ask Claude Code …" or "Tell Claude Code …"; within a conversation, follow-ups usually
go there without it. Any language works: the controller answers in the language of the request, in
at most three short sentences, without code, paths or lists.
| You say | What happens |
|---|
| "Ask Claude Code whether the gateway is up." | ask; an answer within seconds |
| "Tell Claude Code to wait 90 seconds and then let me know." | send, then get_reply every 25 to 40 s; interim status, then the result |
| "What is Claude Code working on?" | status: open requests, approvals, when the controller last answered |
| "Allow." / "Deny." | approve with the five-letter code |
| "Which repositories can you work in?" | list_workers: branch, uncommitted changes, what each worker does; in Docker the level |
| "Ask the alpha worker whether the tests pass." | dispatch; the controller confirms, the result follows as news |
| "Tell the alpha worker to fix the typo in the README." | The worker commits on a voice/ branch, pushes and opens a pull request (in Docker: depends on the level); you hear that it is out |
| "Open it as a draft." / "Ja" to "Es folgt noch die Übersetzung, als Draft öffnen?" | dispatch with draft: that one pull request opens as a draft. The controller asks only when more tasks for the same pull request are queued or named; the last of them marks it ready |
| "Tell it to add a test for that too." | A follow-up in the same worker session, on the same branch |
| "Then also update the changelog in alpha." (while alpha works) | dispatch=queued: it starts by itself after the run, and you hear its result. With several runs per repository, the controller lets it run next to the other in a worktree of its own when it needs nothing from it, and says so: "It runs in parallel to the test fix." |
| "Stop the second alpha run." / "Stop it for good." | stop_worker with that task; a run in a worktree goes back to the queue and resumes, unless you say to stop it for good |
| "What is the alpha worker doing?" | worker_status, with the queue there |
| "Stop the alpha worker." | stop_worker; the next queued task there starts |
| "Forget the changelog task." / "Stop alpha and drop the rest." | stop_worker with drop and the task's id, or with |
Work folders by voice, once they are turned on (work folders), for a task
that needs no repository:
- Start one by saying the task without naming a repository: "Rechne mir die Fahrtkosten nach
Basel aus." or "Draft a reply to the landlord." The controller sends what needs no repository to
a new work folder and tells you it is on its way. To be sure, say so: "In a new work folder:
compare the three phone plans." or "Neuer Arbeitsordner: …". At
unrestricted that is how you
get a run instead of the controller answering itself.
- Follow up in the same conversation: "Make it shorter." or "Und jetzt als Tabelle." continues
in the same folder and Claude session. Later, name its topic, "the folder with the travel costs":
the controller looks among the last work folders with
worker_status. Something new, or "a new
work folder", gets a folder of its own.
- While it runs: "What is the work folder doing?" and "Stop the work folder." work as for a
repository. When every run slot is taken, it waits in the queue.
- The result comes as news: what the files hold, not where. At the desk they are in
~/voice-scratch/<date>-<id> (VOICE_GW_SCRATCH_ROOT), for 60 days after the last run
(VOICE_GW_SCRATCH_DAYS, 0 for good); copy out what you want to keep. There is no git, so no branch, no pull
request and no question about pushing.
Your words. The controller passes your words with every dispatch. If they are not from a voice
request of the last VOICE_GW_DISPATCH_WINDOW minutes, the gateway starts nothing and the
controller asks you first (5.4), unless a standing order
you gave by voice covers the task. When a worker result or a review asks for something, the
controller is told to tell you and let you decide. The one exception is your standing instruction
to fix review comments, which the gateway carries out itself, not the controller
(below).
Open items (src/ledger.mjs), so that nothing is forgotten while you drive:
- Every question is an open decision, kept in
.state/jobs.json: the controller's questions,
of kind push, merge, restart, delete or other, and what the gateway raises itself: a
push decision after a run that left commits on no remote or uncommitted files
(5.5) or a stop, one for a queued task that could
not start or that the kill switch took out of the queue, and one for the gateway's own checkout.
- Pending, asked, answered, closed. Your answer to the question ("(Answer to ) …"), by
voice or by Telegram, is kept with it, and a question back on your
answer is the same decision. It closes when the follow-up
dispatch with your answer starts, when the controller replies "done" to your answer (unless that
follow-up still waits in the queue), or with
resolve_decision and your words. When you decide
in a new request instead of answering, the controller closes it with resolve_decision.
- The controller has to ask. It cannot reply "done" to a request whose decision was never put
to you; the reply tool says why.
- The queue waits. While a decision about a repository is open, its tasks wait
(
dispatch=queued … reason=decision); the follow-up for your answer goes first.
- Reminders. A decision you were never asked about, or a question you have not answered, comes
back to the controller after
VOICE_GW_REMIND_MINUTES (30), at most three times, also one that
belongs to no request yet; none while the kill switch is locked. It asks you, and you hear it as
news.
- What is open?
open_items lists everything in one call. At every start of the controller
session, the gateway sends it the same list as an event, so a restarted controller needs no
memory files.
- Texts. These lists hold what the gateway, the controller and you said; of a worker result
only its ids. The controller's tasks and questions may quote a worker or a pull request, so the
summary, reminder and queue events come with
trusted="no".
Pull requests and reviews:
- Before a push that opens a pull request (a push decision, a public repository), the
controller asks only whether to open it ("Soll ich den PR öffnen?"), without a word about drafts:
it opens ready for review, and its reviews start at once. A draft only when you say so, or when
more tasks for the same pull request are queued: then it asks "Es folgt noch X, als Draft
öffnen?".
- After a push you hear only that the pull request is out, and which one. The controller never
offers a review.
- The watch. The gateway takes the pull request URLs in a run's output and checks with
gh
that each is open on a voice/ branch, comes from the repository itself (not a fork), and was
opened by the account gh is logged in as. It watches those: every VOICE_GW_PR_POLL seconds
(60), five times as long after 15 looks. Without a gh login it watches nothing. In Docker, a
pull request opened with the container's token is watched only if that token belongs to the
same account.
- Later pushes. A later run that names the pull request watches it anew, from the start, also
after the watch stopped, so the reviews and checks that run again reach you. One that pushed to
it or marked it ready without naming it is caught too: when a run ends, the gateway looks again
at the last three pull requests it watched for that repository, and one whose head commit or
draft state changed while the run went counts as the run's.
- What becomes news:
- finished checks, once per push;
- new reviews and comments, except those of the account
gh is logged in as;
- their text only from the repository's owner, members and collaborators. Of anyone else, which
as a rule includes bots such as Copilot, you hear who reviewed or commented, not what;
- the fix the gateway queued for them, or why it queued none (below);
- you hear them as a sentence or two. The controller gets them marked
trusted="no".
- When the watch stops:
- nothing shows up in the first five looks;
- the pull request is merged or closed;
- after
VOICE_GW_PR_WATCH_HOURS (24); a later push starts it again (above).
Automatic fixes for review comments
Your standing instruction: review comments on the workers' pull requests are fixed without
asking, round after round (VOICE_GW_AUTO_FIX_REVIEWS, on by default; the setup assistant offers
it). The watch above does it, in code (src/pulls.mjs, Workers.reviewFix):
- What starts a fix: new inline comments, reviews that request changes or have a text, and
comments with a text, from the repository's owner, members and collaborators, or from a review
bot of
VOICE_GW_REVIEW_BOTS (the Claude app, claude, and Copilot's reviewer). A login on that
list counts only where GitHub does not say it is a person. Anyone else's reviews and comments
start nothing, as their text is not forwarded either. Approvals without a comment start nothing.
- The fix is a task in the gateway's own words, naming the pull request and its branch, never
what the reviews say: the worker reads the threads on GitHub itself, under its rules, and acts
only on those of collaborators and review bots. Its prompt says that the gateway queued it, with
no words of yours.
- Through the queue, always, as the follow-up of the run that pushed the pull request: in its
worktree if it had one, with its session. So it waits like any task: behind a run going there,
while an open decision holds the repository, for a free run slot. One that waits already takes
newer comments too. While the kill switch is locked nothing is queued, and the lock empties the
queue as always.
- Rounds. The fix pushes, the automatic reviews run again, and new comments start the next
round. At most
VOICE_GW_AUTO_FIX_ROUNDS (3) per pull request; then the news says they are used
up, and the controller tells you. A further fix takes your word, and a run you dispatch that
works on the pull request gives it its rounds again.
- What you hear: the news about the review, which says that a fix was queued (with
fix="q…" for the controller, which dispatches nothing for it), and later the fix's result,
both gathered into the digest below while they are routine.
open_items, list_workers and worker_status show such a task as "from the review of pull
request 12"; its runs count in the statistics like any other.
- Off, with
VOICE_GW_AUTO_FIX_REVIEWS=0: news only, and a fix only when you ask for it.
The digest of routine review news
Within a few hours of automatic rounds, one event each made dozens of near-identical news items:
"checks passed", "Claude commented, round 2 queued", "nothing needed fixing". So the gateway
gathers the routine ones and tells the controller once, in one short summary
(VOICE_GW_REVIEW_DIGEST, src/digest.mjs), by fixed rules in its code:
- Routine: checks that all finished without a failure; with automatic fixes on, reviews and
comments that ask for nothing (approvals, others' text, which is not forwarded) or that a fix
was queued for, or that a fix waiting already takes; and an automatic fix that ended done, pushed
nothing to its pull request (its head did not move during the run, as
gh shows it right after),
left nothing on no remote or uncommitted, and does not end with a question. With automatic
fixes off, only passed checks.
- Still one by one: failed checks, comments that ask for changes with no fix queued (the
automatic rounds used up, the kill switch locked), a fix that pushed changes, failed, asks or
left something, and every run you or a standing order dispatched. Such an event takes along the
routine items of its own pull request that were not told yet, so the order stays.
- When: once the news has been quiet for
VOICE_GW_REVIEW_DIGEST minutes (10), at the latest
VOICE_GW_REVIEW_DIGEST_MAX minutes (30) after its first item; not before the controller is
connected, nor in a standby. It reaches the controller as one event, kind="digest",
trusted="no", with items, pull_requests (owner/name#number), fix (the fixes queued) and
tasks (the fix runs) as attributes and one line per item with its time; the controller answers
it once, in two or three sentences, and you hear that as news.
- Nothing lost: the items wait in
.state/digest.json, and leave it only once their digest is
a request, which goes to the controller again after a restart if it was never answered.
open_items and the start-up summary say how many wait, and when they go.
- Off, with
VOICE_GW_REVIEW_DIGEST=0: one event each, as before; items gathered before go
once as one digest at the next start.
Several gateways, and the standby after a disconnect
With several gateways, each a connector in claude.ai (this computer, a Raspberry Pi, a Mac), they
all look alike to the voice assistant (src/presence.mjs):
- Every answer of the connector starts with one line that names the gateway and says whether to
use it:
Gateway sky (Mac): active. The name is VOICE_GW_NAME, by default the computer's, the
one in the gateway's Telegram messages, with the kind of computer (Mac, Raspberry Pi, Linux); a
name with its own brackets gets none added. status gives it first.
- Active means: a controller session is connected to the gateway and the kill switch is not
locked. Inactive, with what then:
- on standby by its settings (
VOICE_GW_STANDBY=1, a test install for example), or in standby
after the connector was disconnected (below): …; use another connector, the only case that
sends the voice assistant elsewhere;
- the kill switch locked:
Gateway sky (Mac): inactive, kill switch locked; it can only be unlocked at the computer, never another connector, which would carry the work past an
emergency stop;
- no controller connected, mostly while it starts or restarts:
…; it is starting or not running; try again in a minute.
- While inactive,
send and ask hand nothing over and say so: with a locked kill switch as
before ([state=locked]), otherwise [state=inactive], with the same advice as the line. So no
request waits for a controller that is not there; status, get_reply and emergency_stop
work in every state.
- The connector's instructions and every tool description name the gateway, and a standby by the
settings; the instructions and
status tell the voice assistant to call status on its
connectors first and to use an active one, and the instructions to switch connectors only when an
answer says so, and that a locked kill switch is unlocked only at the computer. They are fetched
when the connector connects, so the live state is only in the answers.
The standby after a disconnect (src/standby.mjs, VOICE_GW_DISCONNECT_STANDBY, on by
default). A connector removed in claude.ai goes on calling the address without a token when a
voice session starts, and an open question of the controller would go on to Telegram as reminders.
So the gateway notices the disconnect and switches itself off:
- What counts as a disconnect: the connector revokes its token at the gateway's revocation
endpoint (RFC 7009,
/oauth/revoke, named in the sign-in metadata), or its grant is revoked at the
computer (node scripts/pair.mjs revoke), and no connector grant is left; or voice requests from
Anthropic's addresses without a token, or with one the gateway does not know, while the last
connector grant has not been used, by a call or a refresh, for VOICE_GW_DISCONNECT_UNUSED (10)
minutes, and then for VOICE_GW_DISCONNECT_GRACE (15) more minutes neither a use nor a new
sign-in follows. The hourly refresh never counts: it comes with the expired token, which the
gateway knows, and is itself a use. A connector added again signs in within a pairing code's ten
minutes, which the grace waits for. Local programs, other addresses and a gateway that was never
connected never count.
- First one message, on Telegram ("the connector was disconnected (…), I am switching off now")
and as an event to the controller. Then the worker runs stop, their results wait, and nothing new
starts; a run in a worktree goes back to the queue. The queue and the open decisions stay as they
are. No reminders, no Telegram fallback, no news about pull requests, and no alerts: suspicious
requests are only counted, for at most one summary a day; a successful sign-in still goes out at
once. The connector's answers say
inactive, connector disconnected, in standby. The standby
holds across a restart (.state/standby.json).
- The controller session stays running, idle: the gateway runs inside it, and so do the sign-in
and the pairing codes (
scripts/pair.mjs, the setup page) that a reconnect needs. Ending the
session would take them along, and with the development flag the start after it would wait at the
terminal for the channel's confirmation.
- A new sign-in wakes it up: one message that it is back, the controller gets an event and what
waited (the stopped runs' results, the open items), and the queue goes on.
- Off, with
VOICE_GW_DISCONNECT_STANDBY=0: the gateway stays on, as before.
A worker's checkout must not be used at the desk while it runs there: workers switch branches in
your checkouts. A worker never commits, stashes or discards changes it did not make.
The Claude usage limit
When the account's usage limit is reached (the 5-hour or weekly limit, a per-model one, a spend
cap), nothing can run until it resets. The gateway waits it out by itself and goes on afterwards
(src/limits.mjs):
- How it sees it: Claude Code ends such a turn with an API error, in a worker run's
stream-json (
claude -p) and in the controller's transcript alike: error: "rate_limit",
api_error: "usage_limit_reached" with api_error_params.rate_limit_info (resetsAt in Unix
seconds, rateLimitType), a rate_limit_event with status: "rejected", and the text "You've
hit your 5-hour limit · resets 3pm (Europe/Zurich)". Older versions wrote "Claude AI usage limit
reached|". An overloaded or throttled API (error: "overloaded", or "rate_limit"
without usage_limit_reached, after Claude Code's own retries) counts as a shorter wait of the
same kind. Only API errors count, never what a worker writes about rate limits.
- Usage credits are no limit: with usage credits on (once "extra usage"), Claude Code goes on
past the plan's limit by itself, headless runs too, and bills the credits. Its
rate_limit_event then still says status: "rejected", but with overageStatus: "allowed"
(or "allowed_warning") and isUsingOverage: true, so such an event counts only when the
credits are refused too: overageStatus: "rejected" or none, or errorCode: "credits_required". A run on credits goes on undisturbed, and nothing waits or is held. The
gateway notes it once in its log, in list_workers (usage=credits, Credits: …) until the
plan's reset, and in the statistics ("partly on usage credits"; Claude Code then keeps the
prompt cache 5 minutes instead of an hour). When the credits or their monthly spend limit run
out, the run fails at once ("You're out of usage credits · resets 3pm", "You've hit your
monthly spend limit · … · your 5-hour limit resets 3pm"), and that is a wait as below, until
the plan's reset or the spend limit's (overageResetsAt), whichever comes first.
- A worker run that meets it goes back to the queue, in front for its repository, with its
session, and its worktree if it had one, as a run interrupted by a restart does; in its checkout
too, where what it found at its start still counts for the look at the end, so what it left is
the task's. No result event, no push decision, nothing counted as failed (the statistics say
"held up by the usage limit"), and it does not count as one of the three interruptions a run in a
worktree may have. It resumes with a line that says so, once the wait is over.
- The wait: until the reset time Claude Code gives (from
resetsAt, else from the text, in the
computer's time), at least a minute; without one, 15, then 30, then 60 minutes, until a run gets
through again. Per account: the workers on this computer, those in a container (its own login),
and the controller session. Nothing new starts on an account that waits; answers
with , and the queue goes on by itself afterwards. Kept across a restart
().
Night mode
The gateway can work through the night without you (src/night.mjs): it decides what it can,
defers what needs you, goes on with other work meanwhile, and tells you in the morning.
- On: in the quiet hours,
VOICE_GW_QUIET_HOURS (for example 23:00-07:30, in the computer's
time zone), and when you say so: "Good night" by voice (the controller's set_away, on your words
from a recent voice request, as dispatch checks them, or from a request or an answer you typed
on Telegram), or /night or "good night" on Telegram.
Then it lasts until the end of the quiet hours, or 12 hours without them.
- Off: at the end of the quiet hours, or with "I'm back" by voice,
/back or "I'm back" on
Telegram, which also ends the rest of the quiet hours; the next ones count again.
- While it is on:
- Worker runs started in it get a rule: decide what the concept, the task or sensible defaults
allow, choosing the conservative option; note each such choice as an assumption, in the report
and the pull request description; when something really needs you, skip that part, note it as
a deferred question and carry on with the rest, instead of ending with a question. The report
ends with "Assumptions:" and "Deferred questions:".
- Questions and decisions that still come wait for the morning, holding only what they hold by
day: their repository, or their worktree, never other work. No reminders.
- Telegram carries alarms only: the kill switch, a standby, a wait for the usage limit, once each.
No questions, approvals or news; the controller's replies to what you write there yourself
still come back.
- The controller hears that the night began (an event of the gateway), goes on with what is
queued, and chains work within your standing orders.
status and open_items say so.
- In the morning: one summary, on Telegram and as an event for the controller, which tells you
as news: what was done (each run that ended, with its pull request), the assumptions made, the
deferred questions, the decisions waiting, and the waits for the usage limit. The night's news
and its questions on Telegram count as told then. A night that ended while the gateway was down
gets its summary at the next start (
.state/night.json).
Standing orders
A dispatch takes your words from a voice request of the last minutes, so at night nothing new
could start. A standing order lets the controller chain work while you are away
(src/orders.mjs):
- You give it by voice: "Keep going with the Simple Modern milestones in alpha tonight." The
controller makes it with
standing_order: a repository (or scratch/new with work folders on), a
scope in your words, and an expiry, by default the end of the night or the next quiet hours,
otherwise 12 hours, at most 24. It takes your words as dispatch does: never typed ones, and
never a worker's or GitHub's, which can neither make nor extend an order. A new one takes new
words.
- While it holds, the controller may dispatch tasks for that repository within that scope
citing the order (
order="o2ab") instead of fresh words, one after the other as the work needs.
The gateway checks the repository and the expiry, logs each, and the worker reads the scope and
stays within it, noting what falls outside as a deferred question. open_items shows the order
with the tasks dispatched under it.
- What still stops everything: the kill switch, which also ends every order (after an alarm,
you say again what may go on), a standby, and the wait for the usage limit, during which its
tasks wait in the queue.
- Ended when it runs out, or with
end_order when you say so or its work is done
(.state/orders.json).
8.5 Statistics
The gateway counts over its running time, and keeps all-time totals across starts
(src/stats.mjs). Nothing is looked up for it: it counts what the gateway sees anyway.
| Figure | Where it comes from |
|---|
| Running time, starts, and what each start came after (a crash, a restart by voice, of the service) | The gateway's start, and the watchdog's note restarted |
| Voice requests, in conversations (a new one after 10 minutes without a request); questions to you, answered, and how long you took (typical and longest); approvals by voice | The requests, the controller's replies with state "question", the answers "(Answer to …)" (store.mjs) |
| Decisions raised, by kind and whether the gateway raised them itself; closed, by whom, and how long they were open | ledger.mjs |
| Worker runs by outcome (done, failed, stopped), follow-ups, in worktrees, in work folders, interrupted and queued again; by repository (top three, with time and cost); worker time, typical and longest run | The runs as they end (workers.mjs); every work folder counts under "work folders" |
| The most runs at once, and the time with two or more; the same in one repository | The runs' starts and ends |
| Queue: tasks that waited, typical and longest wait; tasks that could not start; dropped | The queue |
| Tokens in (with cache reads and writes) and out (with thinking), cost at API list prices, turns, tool calls, subagents | Each run's output, which the gateway follows anyway: per result model_usage_since_last_result (the usage since the result before, with the cost; total_cost_usd adds up the whole session, earlier runs included), the tool calls, and the Agent (or Task) calls for subagents |
| Commits made during a run, runs that pushed, files and lines changed; pull requests opened and updated; files made or changed in work folders | The kill switch's size check at the end of each run (guard.mjs), the pull requests the gateway watches (pulls.mjs), the check of a work folder |
| Checks finished on those pull requests (and with failures), reviews (approved, changes requested), comments | The watch on pull requests |
| Kill switch locks, and by what | killswitch.mjs |
Not counted: the controller session's own tokens. They are only in its transcript, whose cost
line Claude Code writes when the session ends, and the sum of its messages does not match that
line; neither is cheap and reliable while the session runs. The cost is Claude Code's at API list
prices; on a subscription it is not what is billed.
Where you see them:
-
By voice: "How did it go today?", "What did that cost?", "Statistics of all time." The
controller calls stats (with all_time for all time) and answers from its first sentences:
"In 5 h 20 min: 12 worker runs (10 done, 1 failed, 1 stopped), 3 h 10 min of work, at most 3 at
once. 31 commits and 4 pull requests opened, with 2.2 million tokens, about 23 dollars at API
list prices. 25 voice requests, 6 questions to the driver, answered after about 4 min." The
details follow in the tool's answer, one line per subject, for when you ask.
-
On Telegram: /stats for this running time in detail, /stats all for all time.
-
When the gateway shuts down, on Telegram: on a stop, when the session is quit, and before a
restart on purpose, between "a restart was requested" and "ready again"
(8.2). Not after a crash, which keeps its single message, so a crash loop
sends no statistics at all:
cc-voice-gateway on <host>: the gateway is shutting down now for the restart. Its statistics:
In 5 h 20 min: 12 worker runs (10 done, 1 failed, 1 stopped), 3 h 10 min of work, at most 3 at once. 31 commits and 4 pull requests opened, with 2.2 million tokens, about 23 dollars at API list prices. 25 voice requests, 6 questions to you, answered after about 4 min.
All time, since 2026-10-20 (14 starts): 140 worker runs, 52 h of work, 31 million tokens, about 410 dollars at API list prices, 60 pull requests opened.
A message follows when it is ready again.
Under the watchdog the watchdog sends it, as only it knows whether the session was stopped,
restarted or crashed (scripts/stats.mjs --shutdown); it waits at most three seconds for the
gateway's last save. Without the watchdog the gateway sends it itself as it ends, with a limit of
three seconds; not when it ends for an error, such as its port taken.
-
At the desk: node scripts/stats.mjs (the last running time), node scripts/stats.mjs --all.
The file .state/stats.json (0600): this running time and all time, written atomically a few
seconds after a change, every five minutes, and as the gateway ends; a crash loses only those
seconds. It holds counts, sums, maxima and the last 100 durations per figure for the typical
values (medians), repository names and the process id of the controller session it belongs to:
no texts, prompts or secrets. A start takes the totals from it and counts its own running time
from zero; a file that cannot be read starts the totals again, with a line in the log.
8.6 Connections and testing at the desk
node scripts/pair.mjs # pairing code for the sign-in page, valid five minutes
node scripts/pair.mjs --minutes 10 # the same, valid up to ten minutes
node scripts/pair.mjs list # connected clients and when they last called
node scripts/pair.mjs revoke <id> # disconnect one; "revoke all" disconnects every client
node scripts/fake-voice.mjs # play the voice AI against the local gateway
npm test # both ends without Claude Code or a phone
pair.mjs and fake-voice.mjs talk to the running gateway, so the controller must run. The setup
assistant's page shows pairing codes too, valid ten minutes (section 10, "Connect the Claude app"). A
connector unused for more than 30 days must sign in again. .logs/gateway.log has one line per
connector call and sign-in step, and one per HTTP request with the caller's address
(http 160.79.106.3 funnel POST /mcp 200); it shows whether and how far the voice AI got, and who
else knocked.
8.7 Getting changes out of the container
See 4.5. At levels pr and full the container pushes
by itself; the clean clone still works for trying a branch here first.
8.8 Configuration
Settings come from the environment, then from .secrets/gateway.env. Claude Code starts the
gateway, so environment variables for it go into the env block of controller/.mcp.json. The
gateway reads them at its start: restart the controller after a change. Under the watchdog, every
start reads .secrets/gateway.env again, and the log line watchdog: settings from … shows each
setting with where it came from.
The worker limits apply at once. The gateway watches .secrets/gateway.env (src/live.mjs).
Once a change has settled for a second or two, it reads the file again and applies
VOICE_GW_MAX_WORKERS and VOICE_GW_MAX_WORKERS_PER_REPO without a restart, as it would at a
start: raised per repository, the controller hears which queued tasks could now run next to
others; lowered, runs going run to the end and nothing new starts above the limit. Every other
changed setting is logged as counting from the next start. A value set in the environment from
outside still wins over the file, and the log says so; one the watchdog's start script exported
from the file does not, as it lists those names in VOICE_GW_SETTINGS_FROM_FILE
(scripts/settings.sh). Without that list, when Claude Code or you start the gateway yourself, a
value in the environment that equals the file's at the start counts as the file's. A file that cannot be read, is gone or looks half written
(its last line without an end) keeps the limits as they are; a value that is no whole number from
1 to 100 is ignored, with a log line. The setup assistant says when a running gateway takes its
limits at once.
Gateway (src/config.mjs):
| Variable | Default | Meaning |
|---|
VOICE_GW_PORT | 8787 | Local HTTP port |
VOICE_GW_HOST | 127.0.0.1 | Bind address; keep loopback |
VOICE_GW_SECRETS_DIR | .secrets | Secrets, settings file, admin socket |
VOICE_GW_STATE_DIR | .state | Worker state and queue, run output, requests, open decisions, pull request watches, usage limit waits, night mode, standing orders |
VOICE_GW_SELF_CHECKOUT | This checkout | The checkout whose uncommitted changes and commits on no remote a "done" reply mentions (5.5); empty: none. Tests use a stand-in |
VOICE_GW_REMIND_MINUTES | 30 | Minutes before a decision you were never asked about, or a question you have not answered, comes back to the controller as a reminder, at most three times (8.4) |
VOICE_GW_LOG_FILE | .logs/gateway.log | Empty: stderr only |
VOICE_GW_LOG_MAX_MB | 10 | Past this size the log moves to gateway.log.1, replacing the one before |
VOICE_GW_OAUTH_HOSTS | claude.ai,claude.com | Hosts allowed in redirect addresses and client ID metadata documents |
VOICE_GW_MCP_ALLOW | 160.79.104.0/21, Anthropic's | Ranges (CIDR) or addresses that may call /mcp through the tunnel, comma-separated; any: no limit, for a tunnel without X-Forwarded-For (5.2) |
VOICE_GW_PERMISSION_RELAY |
Start script and channel (scripts/start-controller.sh, scripts/channel-mode.mjs):
| Variable | Default | Meaning |
|---|
VOICE_GW_CONTROLLER_MODEL | Claude Code's default | --model for the controller |
VOICE_GW_CONTROLLER_EFFORT | Claude Code's default | --effort for the controller |
VOICE_GW_CONTROLLER_CHROME | 0 | 1: --chrome for the controller, so it can use Claude in Chrome, at the level unrestricted only: a Chrome with the Claude extension, signed in, open in a window of its own on a desktop of the same user (a remote desktop session that stays when you disconnect works). A security risk: read the warning first. Before each start the script marks Claude in Chrome's notice for the first start as seen, so that the session does not wait at the terminal (8.1); to be sure, confirm it once at the terminal with claude --chrome (7.1) |
VOICE_GW_REMOTE_CONTROL | 0 | 1: --remote-control "Voice controller" for the controller, to follow and steer it at claude.ai/code and in the Claude app (Remote Control); not the workers, whose headless runs Remote Control does not connect. Any other value counts as 0. The gateway reads it too, to say so in its log |
VOICE_GW_WATCHDOG | 1 | 0: start without the watchdog |
VOICE_GW_WATCHDOG_INTERVAL | 30 | Seconds between health checks |
VOICE_GW_WATCHDOG_HEALTH_FAILS | 3 | Failed checks before a restart |
|
The start script exports VOICE_GW_CHANNEL_MODE, VOICE_GW_WATCHDOG_LOCK and
VOICE_GW_SETTINGS_FROM_FILE (the names it took from the settings file at that start) for the
gateway; do not set them.
Container and fetch helper (scripts/start-workers-container.sh,
scripts/fetch-from-container.sh, docker/):
| Variable | Default | Meaning |
|---|
VOICE_GW_DOCKER | voice-workers | The container |
VOICE_GW_DOCKER_FOLDER | ~/docker-developer | Host folder mounted at /repos |
VOICE_GW_SCRATCH_DOCKER | voice-scratch | With --scratch: the work folders' container |
VOICE_GW_SCRATCH_ROOT | ~/voice-scratch | With --scratch: the host folder mounted at /scratch, made if missing |
VOICE_GW_DOCKER_IMAGE | cc-voice-worker | Image, for both containers |
VOICE_GW_DOCKER_REPOS | /repos | Path inside the container |
VOICE_CLEAN_ROOT | The folder holding this repository | Where the clean clones are |
VOICE_SECRETS | /run/secrets/voice | Inside the container: where voice-run and the credential helper read the tokens |
Setup assistant and Funnel (scripts/setup.mjs, scripts/funnel.sh):
| Variable | Default | Meaning |
|---|
VOICE_SETUP_HOST | 0.0.0.0 | Bind address |
VOICE_SETUP_PORT | 8790 | Port |
VOICE_SETUP_TIMEOUT | 30 | Minutes at most |
VOICE_SETUP_IDLE | 10 | Minutes without activity |
VOICE_SETUP_GIT_BIN, VOICE_SETUP_TMUX_BIN, VOICE_SETUP_SYSTEMCTL_BIN, VOICE_SETUP_LOGINCTL_BIN, VOICE_SETUP_LAUNCHCTL_BIN, VOICE_SETUP_PLUTIL_BIN, VOICE_SETUP_NETSTAT_BIN, VOICE_SETUP_SS_BIN, VOICE_SETUP_OSASCRIPT_BIN, VOICE_SETUP_PKEXEC_BIN, VOICE_SETUP_SUDO_BIN, VOICE_SETUP_TAILSCALE_BIN | git, tmux, systemctl, loginctl, launchctl, plutil, netstat, ss, osascript, pkexec, sudo, tailscale | Commands the assistant runs; tests use stand-ins. On macOS it also looks in Homebrew's folders, and for Tailscale in the app |
VOICE_SETUP_HOSTS_FILE | /etc/hosts | The hosts file of "Prepare the sign-in on this computer"; tests use a file of their own |
VOICE_GW_TAILSCALE_BIN | tailscale, on macOS else the Tailscale app's | The tailscale command scripts/funnel.sh runs |
FUNNEL_PORT | VOICE_GW_FUNNEL_PORT, else 443 | Public port of the Funnel for one call of scripts/funnel.sh, over the setting |
8.9 Troubleshooting
| Symptom | Cause and fix |
|---|
| Connector dialog says no server answered, and nothing is in the log | The public address is not on port 443 (claude.ai's connector did not connect to the Funnel on 8443 in our test, although it answers publicly there), or it was only just set up. Tailscale Funnel needs a few minutes on the first start; scripts/funnel.sh start reports when all relays answer. |
| The sign-in in a browser on the gateway's own computer says "Not secure" and shows another server's page, such as Apache's "Not Found", while other devices connect | Another web server on that computer listens on port 443, and connections from the computer to its own tailnet name reach it, not the Funnel; on a Mac with the Tailscale app such a connection is refused when nothing else listens there. Sign in from another device, or "Prepare the sign-in on this computer" in section 10 of the setup page (known issues). |
Sign-in goes to an http:// address or to the wrong host | The tunnel or proxy does not pass the public host name or X-Forwarded-Proto: https (see Publishing the endpoint). |
| Sign-in page says the app is not registered | The sign-in state was reset, for example because .secrets/oauth.json was deleted. Remove the connector and add it again. |
| Sign-in fails with Claude's published identity | Choose to register automatically instead. If the log shows client id document not allowed: <URL>, that host is missing from VOICE_GW_OAUTH_HOSTS. |
| "Wrong or expired code" | "New code" on the setup page, or node scripts/pair.mjs again. A code lasts ten minutes from the page, five from pair.mjs, and one sign-in; the newest code replaces every one before, and a restart of the gateway forgets it. Five wrong attempts from one browser or address, or twenty in all, burn it. |
pair.mjs says the gateway is not running | Start the controller; the gateway runs inside its session. |
| The connector stopped working after weeks unused | The refresh token expired after 30 days. Sign in again from the connector's settings. |
| Claude asks before every tool call |
Claude Code's debug log is at ~/.claude/debug/<session-id>.txt.
8.10 Known issues
With a personal account, every start of the controller waits for a confirmation at the
terminal. A personal claude.ai account, Pro or Max for example, has no organisation and so no
channel allowlist for the plugin (7.4). The controller
then runs with the development channel flag, and Claude Code shows a confirmation prompt at every
start of it. So the gateway cannot start or restart fully by itself: not after a crash, not by the
watchdog, not by the autostart and not by scripts/restart-everything.sh. After every start,
someone has to confirm the dialog in the Claude process through tmux, over SSH if need be:
tmux attach -t voice # then Enter; leave tmux again with Ctrl+B, then D
Until then voice requests do not arrive. The Telegram message that a restart begins says when the
start waits for it (8.2). Officially, fully automatic, unattended operation is
only possible with a Team or Enterprise account whose Owner has put the plugin on the channel
allowlist (8.1, plugin mode).
Workaround, unofficial: the allowlist in the managed settings file on this computer. The
setting of 7.4, put into the managed settings file on
this computer instead of the organisation's, also works with a personal account. Tested with a Max
account and Claude Code 2.1.312: the controller starts in plugin mode, without the confirmation. As
root:
sudo mkdir -p /etc/claude-code
sudo tee /etc/claude-code/managed-settings.json <<'EOF'
{
"channelsEnabled": true,
"allowedChannelPlugins": [
{ "marketplace": "cc-voice-gateway", "plugin": "voice" }
]
}
EOF
On macOS the file is /Library/Application Support/ClaudeCode/managed-settings.json; if it exists
already, add the two keys to it. The start script reads the file and chooses the plugin at the next
start. As in 7.4, the list replaces the default list of channel plugins, here for every user of this
computer. Anthropic documents the allowlist for organisations only, so this is unofficial and may
break with any update of Claude Code: if a start waits for the confirmation again, confirm it as
above and check whether the workaround still holds.
A browser on the gateway's own computer cannot sign in under the tailnet name. The Funnel
answers other devices and Claude's servers as it should, so the Claude app reaches the gateway; only
the sign-in in a browser on the computer the gateway runs on fails. On macOS the Tailscale app's
network extension binds the tailnet address only for ports from 1024 up (netstat -anv -p tcp
shows IPNExtension on 8443, never on 443), so a connection from the Mac to its own tailnet name
on 443 is refused, or, where another web server listens on *:443, such as macOS's built-in Apache
with /etc/apache2/extra/httpd-ssl.conf, gets that server: "Not secure" and its own page. Letting
that server listen only on 127.0.0.1 does not help: the Tailscale app still does not take 443 on
the tailnet address. On Linux the same happens where another web server listens on 443.
Section 10 of the setup page finds out instead of guessing: once the Funnel is on, the assistant
fetches https://<host>.ts.net/.well-known/oauth-authorization-server from this computer, as a
browser here would, and only if what answers is not the gateway (refused, the wrong certificate,
another server's page) it says so, naming another web server on 443 if netstat -anv -p tcp on
macOS or ss -ltnp on Linux shows one. Then, for the one-time sign-in:
- add the connector and sign in from another device, for example claude.ai in a browser on your
phone, with a pairing code from the setup page or
node scripts/pair.mjs;
- or "Prepare the sign-in on this computer": the assistant looks up one of the Funnel's public
addresses through public resolvers (1.1.1.1, 8.8.8.8; not MagicDNS) and adds one marked line to
/etc/hosts, <address> <host>.ts.net # cc-voice-gateway setup: …, so that the browser goes
out through the public Funnel with its valid certificate. It asks for administrator rights: on
macOS the system's password dialog (osascript … with administrator privileges), which also
flushes the resolver's cache (dscacheutil -flushcache, killall -HUP mDNSResponder); on Linux
pkexec, or sudo -n where no password is needed; otherwise the page shows the exact command for
a terminal. It keeps a backup next to the file (/etc/hosts.cc-voice-gateway.bak), never touches
another line, and checks again whether the gateway answers now. It removes the line again, by an
exact match, as soon as the page's pairing code is used, when the page or the assistant ends, and
with "Remove it now"; that may ask for the password a second time. A line an assistant left
behind, after a crash for example, shows on the page with "Remove it now" too.
The setup never changes that other server, and never moves the Funnel off 443: Funnel serves 8443
publicly, but claude.ai's connector did not connect to it in our test ("no server answered at this
URL", and no request from Anthropic's addresses in the log).
9. Repository layout
src/
gateway.mjs entry point: wires both sides, exits with the session
channel.mjs stdio side: channel, reply and worker tools, permission relay
voice.mjs HTTP side: the voice tools
oauth.mjs sign-in: OAuth metadata, registration, pairing page, tokens
ipguard.mjs the door: access log, /mcp only from Anthropic's addresses, alerts on Telegram
presence.mjs which gateway answers the connector, and whether it is active
standby.mjs the standby when the connector is disconnected, until a new sign-in
limits.mjs the Claude usage limit, in runs and the controller's transcript, waited out; credits noted
night.mjs night mode: the quiet hours, good night and I'm back, the morning summary
orders.mjs standing orders: what the controller may dispatch without fresh words, at night
workers.mjs workers: repositories, claude -p runs, the queue, the check after a run, results
workspaces.mjs where a run works: a repository, or a work folder, with its rules and check; run folders
worktrees.mjs the worktrees of further runs of a repository: made, with submodules, removed
live.mjs the worker limits from the settings file while the gateway runs
ledger.mjs open decisions for the driver, and the open items
stats.mjs the statistics: this running time and all time, the texts
runners.mjs where workers run: this computer, or the Docker container; a run's processes
access.mjs access levels in the container, from the GitHub token
pulls.mjs the watch on pull requests that workers open
digest.mjs routine news about them, gathered into one event for the controller
telegram.mjs Telegram fallback; /stop, /funnel, answers and requests from your own chat (off by default), the input log
funnel.mjs the Funnel on and off from Telegram, and off by itself when idle
killswitch.mjs the kill switch: state, emergency phrases, alarm
guard.mjs the kill switch's rules on the workers' runs
restart.mjs restarting the controller by voice, through the watchdog
plugin.mjs the gateway as the MCP server of the plugin voice@cc-voice-gateway
store.mjs requests and approvals, persisted across restarts
folders.mjs cloning and deleting in the worker root, above the level confined
admin.mjs Unix socket for pair.mjs, unlock.mjs, fake-voice and the setup's pairing codes
config.mjs settings from the environment and .secrets/gateway.env
settings.mjs reads and writes .secrets/gateway.env
platform.mjs what differs on macOS: temporary folders, the autostart's PATH and names
setup/ the setup assistant: server.mjs (guard), actions.mjs (steps), ui/ (page)
http.mjs, log.mjs small HTTP helpers; log to stderr and file, never stdout
controller/
.mcp.json starts the gateway as the server "voice"
.claude/settings.json allow and deny rules, the hook
.claude/hooks/confine.mjs the controller's confinement
CLAUDE.md the controller's instructions
playground/ the one folder the controller writes to
.claude-plugin/ this repository as the marketplace cc-voice-gateway, with the plugin voice
docker/ Dockerfile, voice-run, voice-stop, git-credential-voice
scripts/
setup.mjs the setup assistant
start-controller.sh controller with its channel, under the watchdog
restart-everything.sh everything of this gateway down and up again, for when something no longer works
channel-mode.mjs plugin or development flag
controller-settings.mjs the session's --settings for its level
chrome-onboarding.mjs Claude in Chrome's notice for the first start, marked as seen
folder-trust.mjs controller/ marked as trusted, so that no folder trust question waits
claude-config.mjs both changes to Claude Code's ~/.claude.json, under its lock, in one step
settings.sh .secrets/gateway.env for the shell scripts
start-workers-container.sh the worker container, or with --scratch the work folders'; read-only while locked
fetch-from-container.sh a branch from the container into a clean clone, as a bundle
unlock.mjs unlock the kill switch, at the server only
pair.mjs pairing codes (also on the setup page), connections
funnel.sh, check-public.mjs Tailscale Funnel, and a check through its relays
telegram-notify.mjs one Telegram message, for the watchdog
stats.mjs the statistics at the desk, and the shutdown message for the watchdog
fake-voice.mjs play the voice AI at the desk
check-macos.sh the checks on a Mac, PASS, WARN or FAIL each, and the tests (docs/macos.md)
test-progress.mjs the tests as one line in a terminal, written over, for check-macos.sh
test/ one file per area; npm test runs them all, with stand-ins for Claude Code,
gh, docker, the GitHub API and Telegram
env.cjs loaded first: clears the caller's settings (VOICE_GW_*, Claude Code's
session), so the tests run alike from any shell, the controller's too
docs/
*.drawio.svg the diagrams, made with draw.io
index.html the product page (GitHub Pages, from this folder)
macos.md install and test on a Mac, step by step
assets/ the video, its poster, the README thumbnail, the dark architecture diagram,
and the page's Barlow fonts (fonts/, SIL Open Font License)
video/ the video's sources: storyboard.md (the messages), script.json (voiceover),
slides.html (animated scenes), tts.py, build.mjs
10. Sources
- Claude Code: channels, channels reference, enterprise controls
- Claude Code: Remote Control, cross-session messaging, authentication and
setup-token, sandboxing, permissions
- Claude Help Center: voice mode, custom connectors
- MCP authorisation, Tailscale Funnel
- carcode, DashVox, VoiceMode, Claude on CarPlay
- talk-to-claude-code: the model for polling and voice-friendly tool results