claude-phone-local 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +108 -0
- package/Dockerfile +84 -0
- package/LICENSE +22 -0
- package/README.md +231 -0
- package/claude-api-server/package-lock.json +832 -0
- package/claude-api-server/package.json +20 -0
- package/claude-api-server/server.js +540 -0
- package/claude-api-server/structured.js +208 -0
- package/cli/README.md +231 -0
- package/cli/bin/check-publish-safety.js +53 -0
- package/cli/bin/claude-phone.js +23 -0
- package/cli/bin/cli-main.js +284 -0
- package/cli/bin/postinstall.js +20 -0
- package/cli/lib/commands/api-server.js +77 -0
- package/cli/lib/commands/backup.js +69 -0
- package/cli/lib/commands/config/path.js +30 -0
- package/cli/lib/commands/config/reset.js +73 -0
- package/cli/lib/commands/config/show.js +86 -0
- package/cli/lib/commands/device/add.js +143 -0
- package/cli/lib/commands/device/list.js +55 -0
- package/cli/lib/commands/device/remove.js +83 -0
- package/cli/lib/commands/doctor.js +422 -0
- package/cli/lib/commands/logs.js +186 -0
- package/cli/lib/commands/restore.js +172 -0
- package/cli/lib/commands/setup.js +1246 -0
- package/cli/lib/commands/start.js +346 -0
- package/cli/lib/commands/status.js +139 -0
- package/cli/lib/commands/stop.js +101 -0
- package/cli/lib/commands/uninstall.js +183 -0
- package/cli/lib/commands/update.js +205 -0
- package/cli/lib/config.js +113 -0
- package/cli/lib/docker.js +384 -0
- package/cli/lib/mcp-register.js +108 -0
- package/cli/lib/network.js +109 -0
- package/cli/lib/platform.js +85 -0
- package/cli/lib/port-check.js +135 -0
- package/cli/lib/prereqs/checks/compose.js +147 -0
- package/cli/lib/prereqs/checks/disk.js +89 -0
- package/cli/lib/prereqs/checks/docker.js +155 -0
- package/cli/lib/prereqs/checks/network.js +78 -0
- package/cli/lib/prereqs/checks/node.js +95 -0
- package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
- package/cli/lib/prereqs/installers/docker.js +229 -0
- package/cli/lib/prereqs/installers/node.js +254 -0
- package/cli/lib/prereqs/platform.js +175 -0
- package/cli/lib/prereqs/utils/execute.js +208 -0
- package/cli/lib/prereqs/utils/rollback.js +223 -0
- package/cli/lib/prereqs/utils/sudo.js +177 -0
- package/cli/lib/prereqs.js +228 -0
- package/cli/lib/prerequisites.js +115 -0
- package/cli/lib/process-manager.js +176 -0
- package/cli/lib/utils.js +78 -0
- package/cli/lib/validators.js +219 -0
- package/cli/lib/voice-downloader.js +79 -0
- package/docker/entrypoint.sh +110 -0
- package/docker/supervisord.conf +78 -0
- package/docker-compose.yml +40 -0
- package/docs/CLAUDE-CODE-SKILL.md +45 -0
- package/docs/LANGUAGES.md +113 -0
- package/docs/MCP-SERVER.md +111 -0
- package/docs/TROUBLESHOOTING.md +288 -0
- package/mcp-server/index.js +159 -0
- package/mcp-server/package-lock.json +1201 -0
- package/mcp-server/package.json +9 -0
- package/package.json +73 -0
- package/stt-local/Dockerfile +12 -0
- package/stt-local/server.py +45 -0
- package/tts-local/Dockerfile +9 -0
- package/tts-local/server.py +83 -0
- package/voice-app/API-QUERY-CONTRACT.md +238 -0
- package/voice-app/DEPLOYMENT.md +176 -0
- package/voice-app/Dockerfile +20 -0
- package/voice-app/README-OUTBOUND.md +316 -0
- package/voice-app/config/devices.json.example +18 -0
- package/voice-app/index.js +273 -0
- package/voice-app/lib/audio-fork.js +465 -0
- package/voice-app/lib/claude-bridge.js +113 -0
- package/voice-app/lib/connection-retry.js +58 -0
- package/voice-app/lib/conversation-loop.js +490 -0
- package/voice-app/lib/device-registry.js +171 -0
- package/voice-app/lib/http-server.js +242 -0
- package/voice-app/lib/logger.js +35 -0
- package/voice-app/lib/multi-registrar.js +133 -0
- package/voice-app/lib/outbound-handler.js +299 -0
- package/voice-app/lib/outbound-routes.js +417 -0
- package/voice-app/lib/outbound-session.js +324 -0
- package/voice-app/lib/query-routes.js +484 -0
- package/voice-app/lib/registrar.js +144 -0
- package/voice-app/lib/sip-handler.js +490 -0
- package/voice-app/lib/tts-service.js +205 -0
- package/voice-app/lib/whisper-client.js +140 -0
- package/voice-app/package.json +38 -0
- package/voice-app/static/gotit-beep.wav +0 -0
- package/voice-app/static/hold-music.wav +0 -0
- package/voice-app/static/ready-beep.wav +0 -0
- package/voice-app/test/freeswitch-retry.test.js +100 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
Every failure mode below was hit for real on a Windows 11 + Docker Desktop +
|
|
4
|
+
native 3CX SBC setup. Each entry gives the symptom, the log line that identifies
|
|
5
|
+
it, and the fix.
|
|
6
|
+
|
|
7
|
+
## First: where to look
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
docker compose logs -f # everything
|
|
11
|
+
docker logs claude-phone 2>&1 | grep -Ei "CALL |error|503"
|
|
12
|
+
curl http://127.0.0.1:3333/health # host Claude wrapper
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Inside the container:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
docker exec claude-phone bash -c 'IP=$(hostname -i|awk "{print \$1}"); \
|
|
19
|
+
for p in 5070 5080 8021 3000 9001 9002; do \
|
|
20
|
+
(echo >/dev/tcp/$IP/$p) 2>/dev/null && echo "$p UP" || echo "$p DOWN"; done'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`3000`, `9001`, `9002` bind to loopback by design and will read DOWN on the
|
|
24
|
+
container IP — that is correct, not a fault.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Call goes straight to voicemail
|
|
29
|
+
|
|
30
|
+
**Symptom:** "Please record your message." The app never answers.
|
|
31
|
+
|
|
32
|
+
**Log:**
|
|
33
|
+
```
|
|
34
|
+
Connection refused (111) with tcp/[…]:5080
|
|
35
|
+
CALL Error: Sip non-success response: 503
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
drachtio could not reach FreeSWITCH, so it returned 503 and 3CX fell through to
|
|
39
|
+
voicemail.
|
|
40
|
+
|
|
41
|
+
**Cause and fix.** FreeSWITCH binds sofia to the **container IP**, never to
|
|
42
|
+
loopback, and it must *advertise* the address it actually listens on. Check the
|
|
43
|
+
two do not disagree:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
docker exec claude-phone sh -c 'grep -E "ext_sip_ip|ext_rtp_ip" \
|
|
47
|
+
/usr/local/freeswitch/conf/vars_diff.xml'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Expected — they are deliberately different:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
ext_rtp_ip=172.16.14.225 <- your LAN IP, so the SBC can reach the media
|
|
54
|
+
ext_sip_ip=172.18.0.2 <- the container IP, where sofia actually listens
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If `ext_sip_ip` is your LAN IP or `127.0.0.1`, drachtio dials a port with nothing
|
|
58
|
+
behind it. `docker/entrypoint.sh` computes the container IP at runtime; confirm
|
|
59
|
+
it ran:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
[bootstrap] FreeSWITCH SIP will advertise 172.18.0.2; RTP will advertise 172.16.14.225
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Call connects but there is no audio at all
|
|
68
|
+
|
|
69
|
+
**Symptom:** the call is up, no greeting, and nothing you say registers.
|
|
70
|
+
|
|
71
|
+
**Check the SDP** that FreeSWITCH sent back:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
docker logs claude-phone 2>&1 | grep "c=IN IP4"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `c=IN IP4 172.16.14.225` (your LAN IP) — correct.
|
|
78
|
+
- `c=IN IP4 172.18.0.x` (a Docker IP) — **broken**. The SBC is a native Windows
|
|
79
|
+
process and cannot route to the Docker bridge, so RTP dies in both directions.
|
|
80
|
+
|
|
81
|
+
**Cause.** FreeSWITCH's `nat.auto` ACL treats `172.16.0.0/12` as "local". Your
|
|
82
|
+
LAN (`172.16.x`) *and* the Docker bridge (`172.18.x`) both fall inside that
|
|
83
|
+
range, so FreeSWITCH decides the SBC is local and skips external-IP
|
|
84
|
+
substitution.
|
|
85
|
+
|
|
86
|
+
**Fix** — applied at build time in the `Dockerfile`; verify it stuck:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
docker exec claude-phone grep -E "local-network-acl|apply-nat-acl" \
|
|
90
|
+
/usr/local/freeswitch/conf/sip_profiles/mrf.xml
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
You want `local-network-acl = none` present and **no** `apply-nat-acl` line.
|
|
94
|
+
|
|
95
|
+
Also confirm the RTP ports are published: `30000-30100/udp` in
|
|
96
|
+
`docker-compose.yml`.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Greeting plays, then nothing — she never answers
|
|
101
|
+
|
|
102
|
+
**Symptom:** you hear the greeting and the beep, you speak, and nothing happens
|
|
103
|
+
until the call times out.
|
|
104
|
+
|
|
105
|
+
**Log — the tell:**
|
|
106
|
+
```
|
|
107
|
+
LISTEN Got: … bytes
|
|
108
|
+
(no "Finalizing utterance" line ever appears)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
or
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
VAD: isSpeech=true, inSpeech=true, silenceMs=0, RMS=1800, floor=…
|
|
115
|
+
```
|
|
116
|
+
repeating, with `silenceMs` never rising.
|
|
117
|
+
|
|
118
|
+
**Cause.** The VAD never sees end-of-speech. Fixed thresholds assume the line
|
|
119
|
+
goes to digital silence (RMS 0) between words. Through a PBX/SBC the gaps carry
|
|
120
|
+
a **noise floor of roughly RMS 1000–2500**, which sits above a fixed threshold,
|
|
121
|
+
so every frame classifies as speech and the utterance is never finalized.
|
|
122
|
+
|
|
123
|
+
**Fix.** The VAD learns the floor as a rolling 20th percentile and requires
|
|
124
|
+
speech to exceed it by `VAD_NOISE_MULT`. If it still misbehaves, read the
|
|
125
|
+
`floor=` value in the logs and adjust:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
VAD_NOISE_MULT=3.0 # she keeps "hearing" your noise floor -> raise
|
|
129
|
+
VAD_NOISE_MULT=1.8 # she misses quiet speech -> lower
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
> An EMA that only updates on "silent" frames **cannot** fix this: if the floor
|
|
133
|
+
> already exceeds the threshold, no frame is ever classified silent, so the floor
|
|
134
|
+
> never updates. The measurement must not depend on the decision it feeds.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Answers are slow (30–60s of dead air)
|
|
139
|
+
|
|
140
|
+
Break the delay down from the timestamps:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
docker logs claude-phone 2>&1 | grep -E "LISTEN Got|WHISPER |CLAUDE Query|VOICE:"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| Stage | Healthy | If it is slower |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `LISTEN Got` → `WHISPER` | 4s (`small`) / 14s (`medium`) | drop `WHISPER_MODEL` to `small` |
|
|
149
|
+
| `CLAUDE Query` → `VOICE:` | 20–45s | see below |
|
|
150
|
+
|
|
151
|
+
**The big one: MCP startup.** Every turn spawns a fresh `claude` process. Without
|
|
152
|
+
`--strict-mcp-config` it first connects to *every* configured MCP server —
|
|
153
|
+
including remote HTTP ones needing auth, which simply time out. That added ~30s
|
|
154
|
+
to every answer.
|
|
155
|
+
|
|
156
|
+
This is on by default now. To confirm the flag is present:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
grep -n "strict-mcp-config" claude-api-server/server.js
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Set `PHONE_ENABLE_MCP=1` only if you deliberately want MCP tools by phone, and
|
|
163
|
+
accept the latency.
|
|
164
|
+
|
|
165
|
+
**Diagnostic trick:** send a deliberately invalid request. If it fails in ~1s
|
|
166
|
+
while a valid one takes 60s, the gap is process startup (MCP), not reasoning.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## She says she cannot check your PC
|
|
171
|
+
|
|
172
|
+
**Symptom:** *"I'm running on the server side, not your computer."* — untrue. She
|
|
173
|
+
runs as Claude Code **on your PC** with full shell access.
|
|
174
|
+
|
|
175
|
+
**Fix.** The device prompt must tell her so. In `data/config/devices.json`:
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
"prompt": "You are Maya… You are running as Claude Code directly on the user's
|
|
179
|
+
Windows PC, with full shell and file access. When asked about their PC, actually
|
|
180
|
+
RUN a command and report the real result. Never say you cannot check."
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Restart the container (config is a volume, no rebuild needed):
|
|
184
|
+
```bash
|
|
185
|
+
docker compose restart claude-phone
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Container restart-loops immediately
|
|
191
|
+
|
|
192
|
+
**Log:**
|
|
193
|
+
```
|
|
194
|
+
/usr/bin/env: 'bash\r': No such file or directory
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Cause.** CRLF line endings — a Windows checkout or a Windows editor saved
|
|
198
|
+
`docker/entrypoint.sh` with `\r\n`, making the shebang `bash\r`.
|
|
199
|
+
|
|
200
|
+
**Fix.** The `Dockerfile` strips CR at build time and `.gitattributes` forces LF.
|
|
201
|
+
If you hit it anyway:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
sed -i 's/\r$//' docker/entrypoint.sh docker/supervisord.conf
|
|
205
|
+
docker compose build && docker compose up -d
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## SIP registration fails
|
|
211
|
+
|
|
212
|
+
**403 Invalid credentials** — you are registering against the wrong host. Send
|
|
213
|
+
REGISTER to the **local SBC**, not the cloud tenant:
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
SIP_DOMAIN=1752.3cx.cloud # identity, used in From/To
|
|
217
|
+
SIP_REGISTRAR=172.16.14.225 # where REGISTER actually goes (the local SBC)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Port 5060 already in use** — the 3CX SBC owns it. drachtio uses **5070**; keep
|
|
221
|
+
`DRACHTIO_SIP_PORT=5070` and the matching published port.
|
|
222
|
+
|
|
223
|
+
Verify registration:
|
|
224
|
+
```bash
|
|
225
|
+
docker logs claude-phone 2>&1 | grep MULTI-REGISTRAR
|
|
226
|
+
# [MULTI-REGISTRAR] Maya SUCCESS - Registered as ext 17512
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Models re-download on every restart
|
|
232
|
+
|
|
233
|
+
`./data` is not persisting. A healthy restart logs:
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
[bootstrap] voice en_US-lessac-medium already present
|
|
237
|
+
[bootstrap] whisper model cached
|
|
238
|
+
[bootstrap] devices.json already present (left untouched)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Check the bind mount is `./data:/data` in `docker-compose.yml`, that `data/` is
|
|
242
|
+
in `.gitignore`, and that Docker Desktop has file sharing enabled for the drive.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Wrong language, or garbled Hindi/Marathi
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
docker logs claude-phone 2>&1 | grep "WHISPER \["
|
|
250
|
+
# WHISPER [hi -> voice hi_IN-priyamvada-medium]: "…"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- **Wrong language detected** — pin it: `STT_LANGUAGE=hi` (or `mr`, `en`).
|
|
254
|
+
- **Right language, garbled words** — `WHISPER_MODEL=medium` at minimum.
|
|
255
|
+
Marathi is the weakest of the three; `large-v3` helps but is ~3 GB and much
|
|
256
|
+
slower per turn on CPU.
|
|
257
|
+
- **Correct text, wrong voice** — check `LANG_VOICE_MAP` and that the model
|
|
258
|
+
exists: `curl http://127.0.0.1:9002/voices`.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## She talks over me / I cannot interrupt her
|
|
263
|
+
|
|
264
|
+
Barge-in is deliberately hard to trigger so a cough or echo cannot cut her off.
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
docker logs claude-phone 2>&1 | grep "BARGE-IN"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- **Never triggers** — lower `BARGE_MULT` (1.6 → 1.3) or `BARGE_MIN_MS` (320 → 200).
|
|
271
|
+
- **Triggers on its own** — raise them. If it fires while *she* is speaking, the
|
|
272
|
+
audio fork is carrying her own audio back; confirm `mixType: 'mono'` in
|
|
273
|
+
`sip-handler.js`.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## Falling back to the old layout
|
|
278
|
+
|
|
279
|
+
The previous five-container setup is preserved and still works:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
docker compose down
|
|
283
|
+
docker compose -f docker-compose.multi.yml up -d
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Note it needs the container-DNS env values (`DRACHTIO_HOST=drachtio`,
|
|
287
|
+
`FREESWITCH_HOST=freeswitch`, `AUDIO_BASE_URL=http://voice-app:3000`), which the
|
|
288
|
+
unified `docker-compose.yml` overrides to `127.0.0.1`.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* claude-phone MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Gives a Claude Code session a phone: it can ring the user mid-task, say why,
|
|
6
|
+
* and (in conversation mode) talk the decision through, then carry on.
|
|
7
|
+
*
|
|
8
|
+
* Wraps the voice-app HTTP API - it does no SIP work of its own.
|
|
9
|
+
*
|
|
10
|
+
* VOICE_APP_URL where voice-app listens (default http://127.0.0.1:3000)
|
|
11
|
+
* PHONE_DEFAULT_TO number/extension to ring (default from .env SIP_EXTENSION)
|
|
12
|
+
*/
|
|
13
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
14
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
15
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
16
|
+
|
|
17
|
+
const VOICE_APP_URL = process.env.VOICE_APP_URL || 'http://127.0.0.1:3000';
|
|
18
|
+
const DEFAULT_TO = process.env.PHONE_DEFAULT_TO || '';
|
|
19
|
+
|
|
20
|
+
async function api(path, options = {}) {
|
|
21
|
+
const res = await fetch(`${VOICE_APP_URL}${path}`, {
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
...options,
|
|
24
|
+
});
|
|
25
|
+
const text = await res.text();
|
|
26
|
+
let body;
|
|
27
|
+
try { body = JSON.parse(text); } catch { body = { raw: text }; }
|
|
28
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 300)}`);
|
|
29
|
+
return body;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TOOLS = [
|
|
33
|
+
{
|
|
34
|
+
name: 'call_me',
|
|
35
|
+
description:
|
|
36
|
+
'Ring the user on the phone and speak a message. Use when you need a decision, ' +
|
|
37
|
+
'hit a blocker, or finished something they asked to be told about.\n\n' +
|
|
38
|
+
'RETURNS IMMEDIATELY - it does not wait for the call to finish, so keep working ' +
|
|
39
|
+
'on the task while the phone rings.\n\n' +
|
|
40
|
+
'mode="conversation" keeps the line open so they can answer back and the voice ' +
|
|
41
|
+
'agent talks with them. To hear what they said, call call_status with the ' +
|
|
42
|
+
'returned callId once you reach a point where their answer matters. If they do ' +
|
|
43
|
+
'not pick up or say nothing, the call ends by itself and call_status reports it.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
message: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
description: 'What to say when they pick up. One or two spoken sentences, no markdown.',
|
|
50
|
+
},
|
|
51
|
+
mode: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
enum: ['announce', 'conversation'],
|
|
54
|
+
description:
|
|
55
|
+
'announce = say it and hang up. conversation = stay on the line so they can reply.',
|
|
56
|
+
default: 'announce',
|
|
57
|
+
},
|
|
58
|
+
to: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: 'Number or extension to ring. Defaults to the configured one.',
|
|
61
|
+
},
|
|
62
|
+
context: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description:
|
|
65
|
+
'Background for the voice agent so it can answer follow-up questions. ' +
|
|
66
|
+
'Never spoken aloud - put the detail here, not in message.',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
required: ['message'],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'call_status',
|
|
74
|
+
description:
|
|
75
|
+
'Check a call started with call_me: whether it is still up, and in conversation ' +
|
|
76
|
+
'mode what the user actually said. Returns conversationHistory with each turn. ' +
|
|
77
|
+
'Poll this when you need their answer - do not block waiting for it.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: { callId: { type: 'string', description: 'The callId returned by call_me.' } },
|
|
81
|
+
required: ['callId'],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'hangup',
|
|
86
|
+
description: 'End a call that is still in progress.',
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: { callId: { type: 'string' } },
|
|
90
|
+
required: ['callId'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const server = new Server(
|
|
96
|
+
{ name: 'claude-phone', version: '1.0.0' },
|
|
97
|
+
{ capabilities: { tools: {} } }
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
101
|
+
|
|
102
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
103
|
+
const { name, arguments: args = {} } = req.params;
|
|
104
|
+
try {
|
|
105
|
+
if (name === 'call_me') {
|
|
106
|
+
const to = args.to || DEFAULT_TO;
|
|
107
|
+
if (!to) throw new Error('No destination: pass "to" or set PHONE_DEFAULT_TO.');
|
|
108
|
+
|
|
109
|
+
// The API only accepts 'announce' | 'conversation'. Accept the looser words a
|
|
110
|
+
// model might reach for rather than silently sending an unknown mode, which
|
|
111
|
+
// matches neither branch and leaves the call hanging.
|
|
112
|
+
const raw = (args.mode || 'announce').toLowerCase();
|
|
113
|
+
const mode = ['conversation', 'converse', 'talk', 'discuss'].includes(raw)
|
|
114
|
+
? 'conversation'
|
|
115
|
+
: 'announce';
|
|
116
|
+
|
|
117
|
+
const body = {
|
|
118
|
+
to,
|
|
119
|
+
message: args.message,
|
|
120
|
+
mode,
|
|
121
|
+
...(args.context ? { context: args.context } : {}),
|
|
122
|
+
};
|
|
123
|
+
const out = await api('/api/outbound-call', { method: 'POST', body: JSON.stringify(body) });
|
|
124
|
+
const callId = out.callId || 'unknown';
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
content: [{
|
|
128
|
+
type: 'text',
|
|
129
|
+
text:
|
|
130
|
+
`Calling ${to} now (callId=${callId}, status=${out.status || 'started'}). ` +
|
|
131
|
+
'This returned immediately - carry on with the task while it rings.' +
|
|
132
|
+
(mode === 'conversation'
|
|
133
|
+
? `\n\nConversation mode: they can reply. When you need their answer, call ` +
|
|
134
|
+
`call_status with callId=${callId} and read conversationHistory. If they ` +
|
|
135
|
+
'do not answer or stay silent, the call ends on its own.'
|
|
136
|
+
: '\n\nAnnounce mode: it speaks the message and hangs up. No reply is collected.'),
|
|
137
|
+
}],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (name === 'call_status') {
|
|
142
|
+
const out = await api(`/api/call/${encodeURIComponent(args.callId)}`);
|
|
143
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (name === 'hangup') {
|
|
147
|
+
const out = await api(`/api/call/${encodeURIComponent(args.callId)}/hangup`, { method: 'POST' });
|
|
148
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
// Surfaced to the model as a failure it can react to, not a crash.
|
|
154
|
+
return { isError: true, content: [{ type: 'text', text: `claude-phone: ${err.message}` }] };
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await server.connect(new StdioServerTransport());
|
|
159
|
+
console.error('[claude-phone-mcp] ready, voice-app at ' + VOICE_APP_URL);
|