A few weeks ago I had an idea that was more fun than it was sensible. I wanted to pick that phone up, dial an extension, and talk to Claude the way I'd ring a developer. "Hey, can you check something for me and call me back?"
Over two days, 13 and 15 October, that idea turned into a three-container Docker stack on my Plex server. Asterisk registers to UniFi Talk as if it were a phone. Whisper transcribes what I say. Claude Code, running on my normal Claude Pro subscription rather than the paid API, does the thinking inside a locked-down container. A neural voice called Kokoro reads the answer back. Quick answers come back on the same call. Slow ones hang up and ring me back, or leave a message in UniFi Talk's voicemail if I don't pick up. I can ask follow-up questions, and it can answer questions about my own server through my own read-only API, without the AI ever holding the key.
I built it with Claude, Anthropic's assistant, in the Claude app, and I want to be straight about how that worked. I treated it like a very fast contractor. The idea, the requirements, the security model and every call about what the agent should and shouldn't be allowed to touch were mine. So was the infrastructure it runs on: the phone system I'd already bent to my will, the server, and an API I'd written myself in C. Claude wrote most of the code. I ran it on real hardware, read the logs, worked out what was actually going wrong, pushed back when something didn't add up, and decided what to fix and what to leave alone. Along the way we hit a caller ID bug, a Python library clash, three phones fighting over one extension, a Linux file capability quirk that broke ping, and a speed test tool that read my connection at about 60 per cent of its real speed.
This post is the whole story, including the parts that didn't work and the moment I asked whether the entire thing was pointless.
Call simulatorA replay of one call through the stack, using the timings I measured on the real server and running at double speed. The answers are examples of what it says, not live output.
What you ask
Hold limit
Answer if it rings back
Ready to dialAsking: "How full are the Plex disks at the moment?"
Hold window0 / 10 s
- Press dial to place the call.
The Short Version
- What it does: dial 0900 from my desk phone, ask anything, and get a spoken answer on the line within about ten seconds, or a callback when the job takes longer.
- What it runs on: three Docker containers on a small Ubuntu box that also runs Plex. One handles the phone side, one runs Claude Code, and one is a tiny proxy to my homelab API.
- What it costs: nothing extra. It uses Claude Code under my existing Pro plan, plus free local models for speech in both directions.
- What I learnt: a lot about SIP, Asterisk dialplans, Docker networking and firewalls, and how to give an AI agent just enough access to be useful and no more.
- Is it useful? Mostly no as a tool, and very much yes as a project. More on that later.
The Phone Side of My Network
The Server Side
- Access control: an IP allowlist is checked before the TLS handshake even starts. Bearer tokens are stored only as BLAKE2b hashes, never in plain text. Each token carries scopes: system:read, docker:read, docker:logs and admin.
- Abuse handling: clients that misbehave collect strikes and escalating bans, and there's a deny-list for containers that should never be visible.
- Hardening: it's built with hardened compiler flags, runs inside a systemd sandbox, has been fuzzed, and has an optional built-in dashboard.
The Original Pitch, and the Reality Checks
- The phone part is very doable. A bot can register to Talk as an ordinary extension, and from Talk's point of view it's just another phone.
- The replies can't appear on claude.ai. The API and the Claude app are separate products. There's no public API to create a chat in the Claude app or post into one, so phone conversations would need their own transcript log.
- The API is billed separately. It's pay per token through the Anthropic Console and isn't covered by a Pro subscription.
- A callback for every turn is clunky. Dial, speak, hang up, wait, get rung back, listen, repeat. Proper voice agents keep the call open with full-duplex audio.
- That screen points the desktop app at your own inference gateway (something like LiteLLM, Bedrock or Vertex) using API credentials. It moves the app off the Pro backend, not the other way round.
- It's one-way. It doesn't let outside systems push conversations into the app.
- Pro doesn't come with an API key at all.
- Each run has a few seconds of start-up latency, so it's poor for rapid back and forth.
- It shares my normal Pro usage allowance.
- Those conversations don't appear in my claude.ai history.
- I should read the current terms myself, and never put other people on my Pro login.
The Requirements
- Ubuntu, with everything in Docker, in "an isolated container". All the dependencies, Whisper included, live in the containers and not on the host.
- The jobs will be small things. Questions, "check internet latency (ping the gateway and Google etc.)", some light research. Not coding.
- "I don't want the agent to escape from the docker container."
- Calls should only be accepted from the Study Phone.
- The flow: ring the number, it says "please leave instructions then press hash once you're done", I leave a message, it runs, and it calls me back to say the job's done with a quick 15-second summary. If I don't answer, it should leave a proper voicemail using Talk's built-in voicemail.
- Save every conversation in Claude Code on the server.
Version 1: Two Containers, Deliberately Apart
- Asterisk 20.6 (Ubuntu 24.04's package), faster-whisper with the small.en model in int8 on the CPU, Piper for text-to-speech with the en_GB-alan-medium voice (Piper has no Australian voice), ffmpeg, and a Python orchestrator that ties it all together.
- Host networking, to avoid the usual SIP and RTP NAT pain when talking to Talk.
- It holds the SIP password.
- Every Linux capability is dropped except the few it needs to fix file ownership at start-up and then switch to the unprivileged asterisk user. no-new-privileges is on, and memory is capped at 4 GB.
- Node and Claude Code only, running as uid 1000 with a read-only root filesystem, a tmpfs /tmp, every capability dropped, no-new-privileges, and limits on processes, memory and CPU.
- Its own bridge network and no Docker socket.
- Its small HTTP API is published only on the host's 127.0.0.1:8787.
- It is deliberately not given the environment file, so it never sees the SIP password.
- Its only persistent storage is its own home folder, which holds the Claude login and its session history.
- Registration. Asterisk's PJSIP stack registers to Talk as a new third-party device on extension 0900. It listens on UDP 5062 to avoid clashing with anything on the usual 5060, uses RTP ports 10000 to 10200, and speaks G.711 a-law and µ-law. An identify section means SIP is only accepted from the Talk console's address.
- Inbound call. The dialplan checks the caller ID matches the allowed caller (0001), answers, and plays "leave instructions, press hash". Record() stops when I press #, after 10 seconds of silence, or at five minutes, and it keeps the audio even if I hang up. The file is handed to the orchestrator over FastAGI on a loopback port, and the bot says "Got it, I'll ring you back."
- Processing. The orchestrator queues the job, transcribes the audio with Whisper, and posts the text to the agent. The agent runs claude -p --output-format json --max-turns 30 --append-system-prompt … --allowedTools … with my words on standard input.
- The summary. The phone system prompt forces the last line of every answer into a fixed format, SPOKEN_SUMMARY: followed by 35 words or fewer. Plain sentences, no Markdown, no URLs, and units written as words, so text-to-speech can read it aloud in about 15 seconds.
- The callback. Piper renders the summary to an 8 kHz WAV, and the orchestrator drops an Asterisk call file into the outgoing spool. Asterisk rings 0001 for 60 seconds, with two retries.
- Voicemail detection. When I answer the callback, it says "Your job's finished, press 1 to hear the summary." If nobody presses 1 within 6 seconds, it assumes Talk's voicemail has answered instead. It waits for 1.2 seconds of silence, which is the end of the greeting and the beep, and then plays a voicemail version: "Claude here, about your request…"
- Logging. Every job gets a Markdown log with the transcript, the spoken summary, the full answer, the session ID, the number of turns and the duration. The recording is kept alongside it.
Testing From the Other Side of the City
- Subnet router. Tailscale on the Ubuntu box advertises my home LAN to my tailnet, with source NAT turned off.
- Return route. The softphone advertises its Tailscale address for audio, so the gateway needs a static route back to the Tailscale range via the server. Without it, calls connect and you hear nothing at all.
- Codecs. G.711 only, with STUN and ICE off.
Bug 1: Every Call Was "Caller 0900"
Text
Executing [0900@from-talk:1] NoOp("Inbound from 0900 to 0900")
Executing [reject@from-talk:1] NoOp("Rejected caller 0900 - not 0001")
Hangup(21)
Bug 2: Transcription Crashed
Text
File ".../faster_whisper/audio.py", line 46, in decode_audio
with av.open(input_file, mode="r", metadata_errors="ignore") as container:
TypeError: open() got an unexpected keyword argument 'metadata_errors'
The First Real Jobs
| Time | Request, as transcribed | Claude Code | Callback |
|---|---|---|---|
| 19:14 | "Test 1, write me a 10-word story about the sun." | Done in 7 s | Rang, answered after 5 s |
| 19:15 | "Tell me one story about cheese." | Done in 10 s | Rang 31 s, then voicemail |
Three Phones, One Extension
Who gets the callback?Three clients signed in with the same 0001 credentials. Any of them can dial out, but incoming calls only go to the last one that registered.
Desk phonethe Study Phone itselfregistered 1 of 3
Smart-SIP-Phonesoftphone on my Macregistered 2 of 3
Telephoneanother app on my Macgets incoming calls
Putting It on the Login Dashboard
- A status line: "Claude Phone ● ONLINE", "PARTIAL" or "OFFLINE", worked out from the cached list of running containers. PARTIAL names whichever half is down.
- An activity line: "Claude Phone: N job(s) today · last 7:18 pm "first few words of the request"", built with bash globs over the job logs and a read loop that finds the first quoted line.
- An access entry: "dial 0900", read from the environment file by picking out the single line holding the bot's SIP username. It never sources the file, because that file also holds the SIP password. A test run confirmed the password never printed.
- Shell commands: a small aliases file of phone-* functions, covering on, off, restart, rebuild, env, status, sip, logs, activity, jobs, last, job, today, test, ask, login, resume, prompt and help.
"Is This Pointless?"
- The Claude app's voice mode already does voice, better, from anywhere.
- A 15-second summary is far too thin for any real research.
- The callback is fragile. It depends on registration, on voicemail timing, on nobody else holding the extension.
- It costs Pro allowance and adds a system to maintain.
- My actual usage so far had been test jokes and a story about cheese.
The Improvement Menu
- Homelab-aware answers through my own API, which is the one thing the phone app genuinely can't do
- Staying on the line for quick answers
- Pushing the full result to my iPhone with ntfy
- Follow-up questions
- Security work: a PIN, a permanent LAN lockdown, a trimmed tool list, a fresh workspace per job
- The Mac on its own extension, with callbacks going to whoever called
- A morning briefing call
- A better voice, using Kokoro
Version 2: Answering on the Line
- Play a prompt and record until # is pressed, the caller pauses, or the maximum length is reached.
- Submit the recording over FastAGI. The orchestrator checks it while the caller waits: the file must exist, be at least 1.5 seconds long, and have enough audio energy to not be silence. If it passes, the orchestrator sets JOBID on the channel. If not, it leaves it blank and the call ends politely.
- Play "Got it. One sec."
- Loop for up to HOLD_SECONDS. Each time round, check whether a .ready file for the job has appeared. If not, play the hold tone, which is two soft 660 Hz blips about a second and a half long, generated with numpy.
- If it's ready: tell the orchestrator it was delivered, play the answer, then say "Anything else? Ask after the tone, or just hang up." Four seconds of silence counts as a no, and the bot says "No worries. Bye."
- If it's not ready in time: tell the orchestrator to detach the job, say "This one's taking a bit longer. I'll ring you back when it's done", and hang up.
- If I hang up early: anything I said before submitting is still submitted, in callback mode. If I hang up while holding, the job is detached and I get a callback.
Ini
[converse]
exten => s,1,Set(JOBFILE=${SPOOL}/inbox/${EPOCH}-${RAND(1000,9999)}.wav)
same => n,Playback(${PROMPTS}/${PROMPT})
same => n,Record(${JOBFILE},${SIL},{{RECORD_MAX_SECONDS}},k)
same => n,AGI(${AGI}/submit,${JOBFILE},${CALLER},hold,${PARENT})
same => n,GotoIf($["${JOBID}" = ""]?bye)
same => n,Playback(${PROMPTS}/working)
same => n,Set(DEADLINE=$[${EPOCH} + {{HOLD_SECONDS}}])
same => n(wait),GotoIf(${STAT(e,${SPOOL}/outbox/${JOBID}.ready)}?ready)
same => n,GotoIf($[${EPOCH} >= ${DEADLINE}]?later)
same => n,Playback(${PROMPTS}/hold)
same => n,Goto(wait)
same => n(ready),AGI(${AGI}/delivered,${JOBID})
same => n,Playback(${SPOOL}/outbox/${JOBID})
same => n,Set(PARENT=${JOBID})
same => n,Goto(s,1)
same => n(later),AGI(${AGI}/detach,${JOBID})
same => n,Playback(${PROMPTS}/later)
same => n,Hangup()
Follow-ups and Memory
A Better Voice
Asking the Server About Itself, Without Handing Over the Key
- It holds the token. The agent never sees it.
- It exposes exactly 11 fixed GET endpoints: /health, /system, /cpu, /memory, /disks, /temps, /network, /docker, /containers, /container/<name> and /stats/<name>. Each maps to one of homelab-api's /v1/... paths. Container names are checked against a strict pattern before they're used.
- Nothing else gets through. No query strings, no request bodies. Any method other than GET gets a 405. Path length is capped, upstream responses are capped at 512 KB, and there are at most two requests in flight and 20 requests per 10 seconds.
- It checks who's asking. It only answers clients from the agent's own Docker network.
- It pins TLS. It connects to homelab-api over loopback and verifies the certificate against the API's own certificate file, under the name the certificate was issued for.
Python
ROUTES = {
"health": "/v1/health",
"system": "/v1/system",
"cpu": "/v1/system/cpu",
"memory": "/v1/system/memory",
"disks": "/v1/system/disks",
"temps": "/v1/system/temps",
"network": "/v1/system/network",
"docker": "/v1/docker/info",
"containers": "/v1/docker/containers",
}
NAMED = {
"container": "/v1/docker/containers/{name}",
"stats": "/v1/docker/containers/{name}/stats",
}
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
Locking the Agent Down Properly
Firewall mapSend a packet from the agent container to each destination and see which chain decides its fate. Switch between the v1 rules, which lived only in DOCKER-USER, and the v2 rules, which also guard INPUT.
- Internet
- LAN
- This host
- CPHONE-FWD, jumped to from DOCKER-USER. It allows established connections, the agent's own subnet, ping and DNS to the gateway, and nothing else private. Every private range is dropped, including Tailscale's. The internet is allowed, because Claude needs it.
- CPHONE-IN, on INPUT for the agent's bridge interface. It allows established connections and hlproxy's one port, and drops everything else.
Bash
ipt -N CPHONE-IN
ipt -A CPHONE-IN -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
ipt -A CPHONE-IN -d "$HLPROXY_IP" -p tcp --dport "$HLPROXY_PORT" -j ACCEPT
ipt -A CPHONE-IN -j DROP
ipt -I INPUT 1 -i "$BRIDGE" -j CPHONE-IN
Testing Phone Calls Without a Phone
| Scenario | Result |
|---|---|
| Quick job, fake Claude takes 2 s | Hold blips, answer read on the line, "Anything else?", silence, goodbye. No callback. |
| Slow job, 18 s | After 10 s, "I'll ring you back" and a hang-up. The job finished at 22 s and a callback was queued. It couldn't connect because the sandbox has no Talk, which was expected. |
| Callback, press 1, follow-up | The fake caller pressed 1, heard the answer, then asked "And which one is the fullest?" The follow-up resumed the same session in the same thread folder, and the log showed the link to the parent job. |
- Memory and the container list worked, and a missing container gave a friendly 404.
- The homelab tool refused ../admin outright.
- Raw attempts at v1/admin/bans, logs/plex, container/plex/logs and memory?x=1 got 404, 404, 404 and 400. A POST got 405. The fake API only ever received the three allowed paths.
- A wrong certificate name gave "TLS verification failed", a wrong token gave "refused the proxy's token", and a client outside the agent subnet got "forbidden".
Deploying Version 2
- The one-liner I used captured the token into a shell variable, and .env ended up with an empty value. Most likely I'd run it more than once. The token name already existed, so the create command failed, and the capture got nothing. homelab-api only shows a token once, so it was gone.
- The first revoke command had the wrong syntax. The CLI wants token revoke --name claude-phone, not token revoke claude-phone.
- The environment file still held an example token from the template, so hlproxy reported "homelab-api refused the proxy's token".
- A direct curl test against homelab-api, forcing the certificate's name to resolve to loopback, separated "bad token" from "proxy problem".
- A fresh token, systemctl reload homelab-api, paste it in, recreate hlproxy.
- The first health check said "Connection refused", but only because hlproxy had restarted zero seconds earlier.
- Then: {"status":"ok","token":"claude-phone","scopes":["system:read","docker:read"],"client_ip":"127.0.0.1"}.
It Works: The First Real Answers
| Drive | Size | Used | Free | Full |
|---|---|---|---|---|
| Plex media drive one | 4.0 TB | 3.23 TB | 766 GB | 80.9% |
| Plex media drive two | 4.0 TB | 2.77 TB | 1.23 TB | 69.2% |
- 12³⁴: it answered 4,922,235,242,952,026,704,037,113,243,122,008,064. It had no calculator, because Python was taken away, so it worked it out by hand by squaring its way up to 12¹⁷ and squaring that, then cross-checked the size with logarithms. It's exactly right, all 37 digits. You can check its working below.
- Pulp Fiction: a solid summary of about 400 words.
- The pings: "Operation not permitted". That's a bug, and it's the next section.
- The time: "1:10 pm AEDT, Thursday 15 October 2026".
- 2 TB NVMe prices in Australia: a table of price bands. Around 160 to 200 dollars is a bargain, 220 to 260 is fair for a Gen4 drive, 270 to 330 is brand-name pricing, and 380 or more is either Gen5 or overpriced. It cited StaticICE, Scorptec, OzBargain, Camelcamelcamel and Amazon AU. That was the agent's own web research, and nobody independently checked it.
- The spoken summary: "It's 1:10 pm. Twelve to the thirty-fourth is about 4.92 times ten to the thirty-six. Pings were blocked, so no results. A good 2 terabyte drive costs about 220 dollars, under 200 is a bargain."
Check its workingThe agent had no calculator, so it squared its way to the answer by hand. This redoes the same steps in your browser with exact integer maths.
What the agent said over the phone
4,922,235,242,952,026,704,037,113,243,122,008,064- Press check to run the squaring chain.
Bug 3: Ping Wasn't Permitted
Bug 4: The Speed Test That Lied
One connection versus manyThe pipe is my internet plan. Each moving line is one download connection. Run both tools and compare how much of the plan they manage to fill.
Download, as a share of my plan–
1 connection · ping reported as 1,800,000 ms (its failure value)
The Code
Everything That Went Wrong
| Symptom | Real cause | Fix | Lesson |
|---|---|---|---|
| Every call "Rejected caller 0900", then voicemail | callerid= on a PJSIP endpoint also rewrites inbound caller ID | Removed it, caller ID set in the call file, trust_id_inbound | Read what a setting does in both directions |
| TypeError about metadata_errors | faster-whisper and PyAV versions disagreed | Decode with ffmpeg and pass samples to Whisper | Pin dependencies, or remove the fragile layer |
| Same crash after the fix | The container wasn't rebuilt | --build, and check the running container for the fix | Verify the code that's actually running |
| "Not calling back" | Three clients registered as 0001, and calls go to the last one | One device per extension | SIP registration is last writer wins |
| Callbacks missed | Dialling out again while the callback rang | Call once and wait | Patience |
| Agent could reach every host service | DOCKER-USER only sees forwarded traffic, and container to host is INPUT | A separate INPUT chain on a named bridge | Test the host itself, not just the LAN |
| Simulated calls hung | Wait() sends no frames, so Record never hears silence | Play a silence file instead | Simulators must behave like real endpoints |
| Homelab token blank | The capture got nothing when the create failed on a duplicate name | Create by hand, with 2>/dev/null | Don't capture once-only secrets blindly |
| "refused the proxy's token" | An example token left in the environment file | Real token and a reload | Test the credential directly first |
| ping "Operation not permitted" | ping's cap_net_raw file capability can't be granted with every capability dropped | setcap -r, with unprivileged ICMP sockets | Hardening has side effects, so reproduce them |
| Then ping "not found" | Purging libcap2-bin also removed iputils-ping, which depends on it | Only purge curl | Check reverse dependencies before purging |
| Speed test far below the real line speed | A single-threaded Python tool | Ookla's official CLI | Measure with the right instrument |
The Numbers
| Thing | Number |
|---|---|
| Whisper transcription (small.en on the i5-7400) | About 2 s per message |
| Claude Code, per simple job | 6 to 10 s, up to about 15 s |
| Kokoro int8 synthesis (sandbox benchmark) | 3.43 s for 7.4 s of speech |
| End to end for a quick question | About 10 to 15 s, so many land just over the hold limit |
| Talk ringing before voicemail picks up | About 30 s |
| Window to press 1 on a callback | 6 s |
| Agent limits | 2 CPUs, 3 GB RAM, 256 processes, 256 MB noexec /tmp |
| hlproxy | 11 endpoints, 20 requests per 10 s, 512 KB response cap, 128 MB RAM |
| homelab-api health check from the dashboard | 3 ms |
| Containers on the box after install | 11 |
The Honest Limitations
- The Claude app's voice mode does voice better. This is a homelab and learning project with one genuinely useful niche: hands-free "check the server, check the internet, look this up and ring me back" from a desk phone, plus phone-ask over SSH.
- The 10-second hold is tight on this CPU. Many simple questions turn into callbacks. Raising HOLD_SECONDS to 20, or pointing the agent at a Haiku model, helps a lot. Try both in the simulator at the top.
- It uses my Pro allowance. Jobs come out of my normal Claude Pro usage. If I hit the limit mid-job, the callback tells me it failed. It's strictly personal, and nobody else goes on my Pro login.
- Access is per extension, not per person. I chose not to add a PIN, so anyone at the Study Phone can use it.
- Prompt injection is still possible during web research. A malicious page could trick the agent into sending what it can see (homelab metrics, container names, my question) to a website through web fetch. What limits the damage is everything else in this post. It holds no secrets, can't write files, can't reach the LAN or the host's services, and only has read-only homelab access. Anything sensitive can be hidden with homelab-api's container deny-list.
- Phone conversations don't appear in my claude.ai history. They live in the job logs and Claude Code sessions on the server.
- gVisor is optional and untested here, and Ookla's download URL couldn't be verified from the build sandbox.
- There's no Australian text-to-speech voice, so it answers me in a British accent.
What's Next
- Confirm the Ookla build works, and run a real speed test from the agent.
- Make a real voice call on the desk phone with version 2.1.1, now that ping and the speed test are fixed.
- Maybe: raise the hold to 20 seconds, try gVisor, give the Mac its own extension with per-caller callbacks, push full results to my phone with ntfy, or set up a morning briefing call.
- Write up the "clients" document for homelab-api's repository.