Turn your AI agents into a radio station.
Agent Radio serves a continuous live audio stream from your machine — a music bed or silence — into which agent output (TTS'd replies, notifications, alerts) gets injected on demand. Subscribe from a phone with the screen locked and agent speech just arrives, announced by a soft chime.
Why a radio? iOS forbids background microphone access for web apps but happily plays a background audio stream forever. Instead of fighting the platform to make agents listen, this flips it around: agent output rides a stream the phone is already happy to play in your pocket, through earbuds or glasses, hands-free.
https://github.com/buddyh/agent-radio
- Live stream — one continuous MP3 stream, silent by default, that a phone will keep playing locked/backgrounded.
POST /say— any script, agent, hook, or cron job puts spoken text on the air. That's the whole integration contract.- Music bed — optional shuffled playlist that ducks under speech and swells back after.
- Studio line — a web UI to chat with an agent (and hear the reply on air), with pluggable backends.
- Talk-back — record from the browser; it transcribes locally and routes to the selected agent.
- Air log — everything that went out over the air, replayable per-device.
- Chime — a soft ding precedes speech, but only after real silence, so a burst of messages never stacks chimes.
- macOS — the default voice is the built-in
saycommand. - liquidsoap — the streaming engine
(
brew install liquidsoap). - ffmpeg — audio encoding (
brew install ffmpeg). - Python 3 — the server is stdlib-only, no pip installs.
- Optional: WhisperKit
(
brew install whisperkit-cli) for talk-back transcription. - Optional: an agent CLI on your
PATHfor the studio line — any of Claude Code (claude), Codex (codex), ortmux.
./start.sh # liquidsoap (:8500) + the web/API server (:8501)
./stop.sh
./check.sh # gates: ruff, liquidsoap --check, bash -n, HTML parseThen open http://localhost:8501 — the tuner UI. Tap the dial to tune in.
Logs land in logs/, TTS clips in clips/ (auto-pruned after 24h), spoken
history in logs/history.jsonl.
Raw stream for VLC or another player: http://localhost:8500/stream.
If you keep the stack running under a supervisor, probe the telnet port, not the stream:
python3 healthcheck.py telnet 127.0.0.1 8502 # cheap, run it often
python3 healthcheck.py stream http://127.0.0.1:8500/stream # costly, run it rarely/stream is an infinite MP3 body. A probe that opens it, reads a few KB and
closes is a client leaving mid-stream: the close sends FIN while harbor is still
writing, so the descriptor stays parked in liquidsoap's poll set, permanently
ready. poll() then returns instantly forever.
Polling it every 15s pinned liquidsoap at ~90% CPU for 41 hours straight on the
author's machine — nine scheduler threads burning ~25 CPU-hours, mostly system
time in poll(), while the mp3 encoder thread sat 96% idle. Harbor also logs
Timeout error for <peer> for every such client, one-for-one.
The telnet probe (uptime) has none of those costs and is a better liveness
signal anyway: it proves the scheduler is servicing requests, where a TCP
connect only proves the port is bound. When you do pull real audio, reset the
connection rather than closing it — healthcheck.py shows the SO_LINGER
dance. That frees the descriptor immediately, though it does not suppress the
log line; only probing rarely does that.
Liquidsoap never rotates logs/liquidsoap.log; long uptimes need it trimmed.
The server binds to loopback and is meant to be reached over a private network, never the public internet (there is no authentication — see Security). The simplest safe path:
-
Put both devices on a Tailscale tailnet (or any VPN/LAN you trust).
-
Point a reverse proxy on this machine at
localhost:8501and give it a hostname your phone can resolve. Caddy is a two-line config:radio.example.ts.net { reverse_proxy localhost:8501 } -
Open that hostname on the phone. Use HTTPS if you want talk-back — the microphone API (
getUserMedia) requires a secure context.
Playback continues with the phone locked. That's the point.
curl -X POST http://localhost:8501/say \
-H 'Content-Type: application/json' \
-d '{"text": "your build finished, and all tests passed"}'Wire this into anything — a CI hook, a cron job, a notification script, an agent tool. A chime precedes speech only after 20+ seconds of quiet, so bursts don't stack.
The UI chats with an agent and speaks the reply on air. Backends are
capability-detected — a chip only appears if its CLI is on your PATH, so
you see exactly what you have installed:
| Backend | How |
|---|---|
| claude | headless claude -p --resume <sid> (default permissions) |
| codex | codex exec / codex exec resume <thread> |
| tmux | types into a running tmux session, captures the pane reply |
| orchy | example: a personal agent's HTTP API |
| openclaw | example: a personal agent gateway CLI |
orchy and openclaw are included as examples of wiring the radio into your
own agent stack — they only appear if you happen to have those tools. To add
your own, drop an entry in the BACKENDS registry in radio_server.py: a
label, a detect predicate, and a run(convo, target, text) function.
POST /agent returns a job id; poll GET /jobs/<id>. Replies come back to the
page and (toggle, default on) get spoken on the stream.
The mic button records while the stream keeps playing (the page must be open — iOS only blocks the mic for backgrounded tabs), transcribes locally with WhisperKit, and routes the transcript to the selected backend:
POST /talk {"audio_base64", "mime_type", "backend", "target"?, "convo"?, "speak"?}
-> {"transcript": "...", "job": "..."}
Drop audio files into ./music/ — the playlist watches the folder and
shuffles. Toggle from the UI or POST /music {"on": true}. Music ducks under
agent speech and swells back after (liquidsoap smooth_add). Override the
folder with AGENT_RADIO_MUSIC=/path/to/dir.
Spotify seam. Streaming Spotify directly isn't possible via API, but you can run spotifyd (a headless Spotify Connect client) and pipe its output into the
playlist()source inradio.liq— then DJ from the Spotify app. Marked in the file.
- No authentication on any endpoint. Agent Radio is built for a private
network you control. The server binds
127.0.0.1; a reverse proxy on a tailnet/VPN/trusted-LAN is the intended and only exposure. Do not put it on the public internet. - Anything you
POST /sayis spoken out loud wherever the listener is. Don't inject secrets. - The studio-line agents run with their own default permissions; the bundled
claudebackend does not skip permission prompts.
The default voice is macOS say (offline, free, instant). Voices are set
per channel — a channel is a speech source: an agent backend name
(claude/codex/tmux/orchy/openclaw) or say for the /say endpoint.
Copy config.example.json to config.local.json (gitignored) and edit the
tts block:
{
"tts": {
"default": { "provider": "say" },
"providers": {
"elevenlabs": {
"voice_id": "your-voice-id",
"model_id": "eleven_turbo_v2_5",
"api_key_env": "ELEVENLABS_API_KEY"
}
},
"channels": {
"openclaw": { "provider": "elevenlabs" }
}
}
}- Per channel: a source uses its
channelsentry if present, otherwisedefault. The example above givesopenclawan ElevenLabs voice and leaves everything else onsay. A channel can also override provider settings (e.g. a differentvoice_id) underchannels.<source>.<provider>. - API keys never live in the config. A provider reads its key from the
environment variable named by
api_key_env. Put the export in.env.local(gitignored) —start.shsources it — or set it in your shell. - Any provider failure falls back to
say, so the radio never goes mute; the error is logged. GET /configreports the resolved voice for every channel (no secrets), and the gear icon in the UI opens a Settings panel to set each channel's provider and voice ID live (POST /configpersists toconfig.local.jsonand hot-reloads — no restart). API keys are never written from the UI.
Add your own provider in three steps, no framework: write a
your_provider(text, opts) -> mp3_path function in radio_server.py, register
it in the TTS_PROVIDERS dict, and reference it by name from a channel.
opts is the merged provider + per-channel settings.
web UI + API (radio_server.py, 127.0.0.1:8501)
GET / /stream /backends /sessions /recent /clips/:name /music /jobs/:id
POST /say /agent /talk /music
| text -> `say` -> ffmpeg -> clips/*.mp3 (chime after 20s+ of quiet)
v
liquidsoap request.queue ------ telnet 127.0.0.1:8502 "say.push <clip>"
| smooth_add ducking over:
| music bed (./music/, watched, toggleable) -> silence
v
output.harbor MP3 stream 127.0.0.1:8500/stream (proxied at :8501/stream)
|
v
your reverse proxy reachable on your private network
MIT — see LICENSE.