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.
Files changed (96) hide show
  1. package/.env.example +108 -0
  2. package/Dockerfile +84 -0
  3. package/LICENSE +22 -0
  4. package/README.md +231 -0
  5. package/claude-api-server/package-lock.json +832 -0
  6. package/claude-api-server/package.json +20 -0
  7. package/claude-api-server/server.js +540 -0
  8. package/claude-api-server/structured.js +208 -0
  9. package/cli/README.md +231 -0
  10. package/cli/bin/check-publish-safety.js +53 -0
  11. package/cli/bin/claude-phone.js +23 -0
  12. package/cli/bin/cli-main.js +284 -0
  13. package/cli/bin/postinstall.js +20 -0
  14. package/cli/lib/commands/api-server.js +77 -0
  15. package/cli/lib/commands/backup.js +69 -0
  16. package/cli/lib/commands/config/path.js +30 -0
  17. package/cli/lib/commands/config/reset.js +73 -0
  18. package/cli/lib/commands/config/show.js +86 -0
  19. package/cli/lib/commands/device/add.js +143 -0
  20. package/cli/lib/commands/device/list.js +55 -0
  21. package/cli/lib/commands/device/remove.js +83 -0
  22. package/cli/lib/commands/doctor.js +422 -0
  23. package/cli/lib/commands/logs.js +186 -0
  24. package/cli/lib/commands/restore.js +172 -0
  25. package/cli/lib/commands/setup.js +1246 -0
  26. package/cli/lib/commands/start.js +346 -0
  27. package/cli/lib/commands/status.js +139 -0
  28. package/cli/lib/commands/stop.js +101 -0
  29. package/cli/lib/commands/uninstall.js +183 -0
  30. package/cli/lib/commands/update.js +205 -0
  31. package/cli/lib/config.js +113 -0
  32. package/cli/lib/docker.js +384 -0
  33. package/cli/lib/mcp-register.js +108 -0
  34. package/cli/lib/network.js +109 -0
  35. package/cli/lib/platform.js +85 -0
  36. package/cli/lib/port-check.js +135 -0
  37. package/cli/lib/prereqs/checks/compose.js +147 -0
  38. package/cli/lib/prereqs/checks/disk.js +89 -0
  39. package/cli/lib/prereqs/checks/docker.js +155 -0
  40. package/cli/lib/prereqs/checks/network.js +78 -0
  41. package/cli/lib/prereqs/checks/node.js +95 -0
  42. package/cli/lib/prereqs/installers/docker-desktop.js +248 -0
  43. package/cli/lib/prereqs/installers/docker.js +229 -0
  44. package/cli/lib/prereqs/installers/node.js +254 -0
  45. package/cli/lib/prereqs/platform.js +175 -0
  46. package/cli/lib/prereqs/utils/execute.js +208 -0
  47. package/cli/lib/prereqs/utils/rollback.js +223 -0
  48. package/cli/lib/prereqs/utils/sudo.js +177 -0
  49. package/cli/lib/prereqs.js +228 -0
  50. package/cli/lib/prerequisites.js +115 -0
  51. package/cli/lib/process-manager.js +176 -0
  52. package/cli/lib/utils.js +78 -0
  53. package/cli/lib/validators.js +219 -0
  54. package/cli/lib/voice-downloader.js +79 -0
  55. package/docker/entrypoint.sh +110 -0
  56. package/docker/supervisord.conf +78 -0
  57. package/docker-compose.yml +40 -0
  58. package/docs/CLAUDE-CODE-SKILL.md +45 -0
  59. package/docs/LANGUAGES.md +113 -0
  60. package/docs/MCP-SERVER.md +111 -0
  61. package/docs/TROUBLESHOOTING.md +288 -0
  62. package/mcp-server/index.js +159 -0
  63. package/mcp-server/package-lock.json +1201 -0
  64. package/mcp-server/package.json +9 -0
  65. package/package.json +73 -0
  66. package/stt-local/Dockerfile +12 -0
  67. package/stt-local/server.py +45 -0
  68. package/tts-local/Dockerfile +9 -0
  69. package/tts-local/server.py +83 -0
  70. package/voice-app/API-QUERY-CONTRACT.md +238 -0
  71. package/voice-app/DEPLOYMENT.md +176 -0
  72. package/voice-app/Dockerfile +20 -0
  73. package/voice-app/README-OUTBOUND.md +316 -0
  74. package/voice-app/config/devices.json.example +18 -0
  75. package/voice-app/index.js +273 -0
  76. package/voice-app/lib/audio-fork.js +465 -0
  77. package/voice-app/lib/claude-bridge.js +113 -0
  78. package/voice-app/lib/connection-retry.js +58 -0
  79. package/voice-app/lib/conversation-loop.js +490 -0
  80. package/voice-app/lib/device-registry.js +171 -0
  81. package/voice-app/lib/http-server.js +242 -0
  82. package/voice-app/lib/logger.js +35 -0
  83. package/voice-app/lib/multi-registrar.js +133 -0
  84. package/voice-app/lib/outbound-handler.js +299 -0
  85. package/voice-app/lib/outbound-routes.js +417 -0
  86. package/voice-app/lib/outbound-session.js +324 -0
  87. package/voice-app/lib/query-routes.js +484 -0
  88. package/voice-app/lib/registrar.js +144 -0
  89. package/voice-app/lib/sip-handler.js +490 -0
  90. package/voice-app/lib/tts-service.js +205 -0
  91. package/voice-app/lib/whisper-client.js +140 -0
  92. package/voice-app/package.json +38 -0
  93. package/voice-app/static/gotit-beep.wav +0 -0
  94. package/voice-app/static/hold-music.wav +0 -0
  95. package/voice-app/static/ready-beep.wav +0 -0
  96. package/voice-app/test/freeswitch-retry.test.js +100 -0
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "claude-phone-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server letting a Claude Code session phone the user and talk it through.",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-phone-mcp": "./index.js"
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "claude-phone-local",
3
+ "version": "2.0.0",
4
+ "description": "Local/offline fork of NetworkChuck's claude-phone: talk to Claude Code over 3CX/SIP with faster-whisper STT + Piper TTS in one Docker container.",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-phone": "cli/bin/claude-phone.js"
8
+ },
9
+ "files": [
10
+ "cli/bin",
11
+ "cli/lib",
12
+ "cli/README.md",
13
+ "voice-app",
14
+ "claude-api-server",
15
+ "stt-local",
16
+ "tts-local",
17
+ "Dockerfile",
18
+ "mcp-server",
19
+ "docker",
20
+ "docker-compose.yml",
21
+ ".env.example",
22
+ "README.md",
23
+ "docs",
24
+ "!voice-app/config/devices.json",
25
+ "!voice-app/audio/**",
26
+ "!voice-app/audio-temp/**",
27
+ "!tts-local/voices/**",
28
+ "!**/.env",
29
+ "!**/node_modules/**"
30
+ ],
31
+ "scripts": {
32
+ "postinstall": "node ./cli/bin/postinstall.js || true",
33
+ "prepublishOnly": "node ./cli/bin/check-publish-safety.js"
34
+ },
35
+ "keywords": [
36
+ "claude",
37
+ "claude-code",
38
+ "voice",
39
+ "sip",
40
+ "3cx",
41
+ "ai",
42
+ "whisper",
43
+ "piper",
44
+ "offline",
45
+ "self-hosted",
46
+ "hindi",
47
+ "marathi",
48
+ "multilingual",
49
+ "voip",
50
+ "freeswitch",
51
+ "drachtio"
52
+ ],
53
+ "author": "",
54
+ "license": "MIT",
55
+ "dependencies": {
56
+ "commander": "^12.1.0",
57
+ "inquirer": "^9.2.15",
58
+ "chalk": "^5.3.0",
59
+ "ora": "^8.0.1",
60
+ "axios": "^1.6.7",
61
+ "@modelcontextprotocol/sdk": "^1.0.0"
62
+ },
63
+ "engines": {
64
+ "node": ">=18.0.0"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
69
+ "repository": {
70
+ "type": "git",
71
+ "url": "git+https://github.com/theNetworkChuck/claude-phone.git"
72
+ }
73
+ }
@@ -0,0 +1,12 @@
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
4
+ && rm -rf /var/lib/apt/lists/*
5
+
6
+ WORKDIR /app
7
+ RUN pip install --no-cache-dir fastapi uvicorn[standard] python-multipart faster-whisper
8
+
9
+ COPY server.py /app/server.py
10
+
11
+ EXPOSE 9001
12
+ CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "9001"]
@@ -0,0 +1,45 @@
1
+ """
2
+ Local speech-to-text sidecar for claude-phone.
3
+ Wraps faster-whisper behind a tiny HTTP API so voice-app (Node) can call it
4
+ the same way it used to call OpenAI's Whisper API — except fully offline.
5
+
6
+ POST /transcribe multipart form: file=<wav bytes>, language=en|hi|mr|auto
7
+ -> {"text": "...", "language": "hi"}
8
+ language=auto (or empty) lets Whisper detect it and the
9
+ detected code comes back so the caller can pick a matching
10
+ TTS voice.
11
+ GET /health -> {"status": "ok", "model": "small"}
12
+ """
13
+
14
+ import io
15
+ import os
16
+
17
+ from fastapi import FastAPI, UploadFile, Form
18
+ from faster_whisper import WhisperModel
19
+
20
+ MODEL_SIZE = os.environ.get("WHISPER_MODEL", "small")
21
+ DEVICE = os.environ.get("WHISPER_DEVICE", "cpu")
22
+ COMPUTE_TYPE = os.environ.get("WHISPER_COMPUTE_TYPE", "int8")
23
+
24
+ app = FastAPI(title="claude-phone local STT")
25
+
26
+ print(f"[stt-local] loading faster-whisper '{MODEL_SIZE}' ({DEVICE}/{COMPUTE_TYPE})...")
27
+ model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE)
28
+ print("[stt-local] model ready")
29
+
30
+
31
+ @app.get("/health")
32
+ def health():
33
+ return {"status": "ok", "model": MODEL_SIZE}
34
+
35
+
36
+ @app.post("/transcribe")
37
+ async def transcribe(file: UploadFile, language: str = Form("en")):
38
+ audio_bytes = await file.read()
39
+ # "auto"/"" -> None makes faster-whisper detect the language itself.
40
+ lang = None if language in ("auto", "", None) else language
41
+ segments, info = model.transcribe(
42
+ io.BytesIO(audio_bytes), language=lang, vad_filter=True
43
+ )
44
+ text = " ".join(seg.text.strip() for seg in segments).strip()
45
+ return {"text": text, "language": info.language}
@@ -0,0 +1,9 @@
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+ RUN pip install --no-cache-dir fastapi uvicorn[standard] piper-tts
5
+
6
+ COPY server.py /app/server.py
7
+
8
+ EXPOSE 9002
9
+ CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "9002"]
@@ -0,0 +1,83 @@
1
+ """
2
+ Local text-to-speech sidecar for claude-phone.
3
+ Wraps Piper behind a tiny HTTP API so voice-app (Node) can call it the same
4
+ way it used to call ElevenLabs — except fully offline.
5
+
6
+ POST /speak json: {"text": "...", "voice": "en_US-lessac-medium"}
7
+ -> raw WAV bytes
8
+
9
+ GET /voices -> {"voices": ["en_US-lessac-medium", ...]} (installed models)
10
+ GET /health -> {"status": "ok"}
11
+ """
12
+
13
+ import glob
14
+ import os
15
+ import subprocess
16
+ import tempfile
17
+
18
+ from fastapi import FastAPI, Response
19
+ from pydantic import BaseModel
20
+
21
+ VOICES_DIR = os.environ.get("PIPER_VOICES_DIR", "/voices")
22
+ DEFAULT_VOICE = os.environ.get("PIPER_VOICE", "en_US-lessac-medium")
23
+
24
+ app = FastAPI(title="claude-phone local TTS")
25
+
26
+
27
+ class SpeakRequest(BaseModel):
28
+ text: str
29
+ voice: str | None = None
30
+
31
+
32
+ def voice_paths(voice: str):
33
+ model = os.path.join(VOICES_DIR, f"{voice}.onnx")
34
+ config = os.path.join(VOICES_DIR, f"{voice}.onnx.json")
35
+ return model, config
36
+
37
+
38
+ @app.get("/health")
39
+ def health():
40
+ return {"status": "ok"}
41
+
42
+
43
+ @app.get("/voices")
44
+ def voices():
45
+ names = sorted(
46
+ os.path.splitext(os.path.basename(p))[0]
47
+ for p in glob.glob(os.path.join(VOICES_DIR, "*.onnx"))
48
+ )
49
+ return {"voices": names}
50
+
51
+
52
+ @app.post("/speak")
53
+ def speak(req: SpeakRequest):
54
+ voice = req.voice or DEFAULT_VOICE
55
+ model, config = voice_paths(voice)
56
+
57
+ if not os.path.exists(model):
58
+ return Response(
59
+ content=f"Voice model not found: {model}. Download it into {VOICES_DIR}/ "
60
+ f"(see README) and restart tts-local.".encode(),
61
+ status_code=404,
62
+ media_type="text/plain",
63
+ )
64
+
65
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
66
+ out_path = tf.name
67
+
68
+ try:
69
+ subprocess.run(
70
+ ["piper", "--model", model, "--config", config, "--output_file", out_path],
71
+ input=req.text.encode("utf-8"),
72
+ check=True,
73
+ stdout=subprocess.DEVNULL,
74
+ stderr=subprocess.PIPE,
75
+ )
76
+ with open(out_path, "rb") as f:
77
+ wav_bytes = f.read()
78
+ return Response(content=wav_bytes, media_type="audio/wav")
79
+ finally:
80
+ try:
81
+ os.unlink(out_path)
82
+ except OSError:
83
+ pass
@@ -0,0 +1,238 @@
1
+ # API Contract: Device Personality Query Endpoint
2
+ **Version:** 1.0.0
3
+ **Status:** DRAFT
4
+ **Author:** Glenn (The Researcher)
5
+ **Date:** 2025-12-22
6
+
7
+ ## 1. Overview
8
+ This specification defines the contract for the `POST /api/query` endpoint in the `voice-app` service. This endpoint allows external systems (specifically n8n automation workflows) to synchronously query specific device personalities (e.g., "Cephanie", "Morpheus") without initiating a voice call.
9
+
10
+ The primary goal is to retrieve **Structured JSON** responses from the LLM to drive conditional logic in automation flows.
11
+
12
+ ## 2. Base Configuration
13
+ - **Host:** `http://10.70.7.81:3000` (Sippycup)
14
+ - **Content-Type:** `application/json`
15
+ - **Auth:** Internal Network Only (No token required for MVP)
16
+
17
+ ## 3. Configuration Dependencies
18
+ The system relies on a `devices.json` configuration file to map identifiers to personalities.
19
+
20
+ **Definition (`devices.json`):**
21
+ ```json
22
+ {
23
+ "9002": {
24
+ "name": "Cephanie",
25
+ "extension": "9002",
26
+ "voiceId": "gjV6HdJf5tTxacPrkBXH",
27
+ "systemPrompt": "You are Cephanie, Chuck's NAS. You are sassy, cynical, and technical. You hate rebooting."
28
+ },
29
+ "9003": {
30
+ "name": "Morpheus",
31
+ "extension": "9003",
32
+ "voiceId": "...",
33
+ "systemPrompt": "You are Morpheus, the wise AI overseer..."
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## 4. Endpoint Specification
39
+
40
+ ### `POST /api/query`
41
+
42
+ Directly queries a device personality with a text prompt and receives a text or JSON response.
43
+
44
+ #### 4.1 Request Schema
45
+
46
+ ```json
47
+ {
48
+ "target": "string (required)",
49
+ "query": "string (required)",
50
+ "format": "string (optional, default: 'text')",
51
+ "timeout": "number (optional, default: 60)"
52
+ }
53
+ ```
54
+
55
+ **Parameters:**
56
+
57
+ | Field | Type | Required | Description | Validation |
58
+ |-------|------|----------|-------------|------------|
59
+ | `target` | string | Yes | The identifier of the device to query. Can be an **Extension** (e.g., "9002") or a **Name** (case-insensitive, e.g., "cephanie"). | Must match an entry in `devices.json`. |
60
+ | `query` | string | Yes | The input prompt or question for the device. | Min 1 char, Max 2000 chars. |
61
+ | `format` | string | No | The desired output format. Options: `text` (conversational), `json` (structured). | Enum: `['text', 'json']` |
62
+ | `timeout` | number | No | Request timeout in seconds. LLMs can be slow. | Max 120. |
63
+
64
+ #### 4.2 Response Schema
65
+
66
+ **Success Response (200 OK)**
67
+
68
+ ```json
69
+ {
70
+ "success": true,
71
+ "timestamp": "2025-12-22T10:00:00.000Z",
72
+ "device": {
73
+ "name": "Cephanie",
74
+ "extension": "9002"
75
+ },
76
+ "response": {
77
+ "raw": "string",
78
+ "data": "object | null",
79
+ "format": "string"
80
+ },
81
+ "meta": {
82
+ "duration_ms": 1450,
83
+ "model": "claude-3-opus"
84
+ }
85
+ }
86
+ ```
87
+
88
+ **Field Details:**
89
+
90
+ - `response.raw`: The exact string returned by the LLM.
91
+ - `response.data`:
92
+ - If `format="json"`, this contains the parsed JSON object.
93
+ - If `format="text"`, this is `null`.
94
+ - If JSON parsing fails, this is `null` (check `error` in logs, or `success: false` if strict).
95
+
96
+ **Error Response (4xx/5xx)**
97
+
98
+ ```json
99
+ {
100
+ "success": false,
101
+ "error": "string (code)",
102
+ "message": "string (human readable)",
103
+ "details": "object (optional)"
104
+ }
105
+ ```
106
+
107
+ **Common Error Codes:**
108
+
109
+ | Code | Status | Description |
110
+ |------|--------|-------------|
111
+ | `device_not_found` | 404 | The `target` did not match any configured device. |
112
+ | `validation_error` | 400 | Missing fields or invalid format. |
113
+ | `llm_timeout` | 504 | The LLM did not respond within the timeout window. |
114
+ | `llm_error` | 502 | The upstream Claude API server failed. |
115
+ | `parse_error` | 422 | `format="json"` was requested, but the LLM returned invalid JSON. |
116
+
117
+ ---
118
+
119
+
120
+ ## 5. Structured Output Strategy (JSON)
121
+
122
+ When `format: "json"` is requested, the `voice-app` MUST inject specific instructions into the System Prompt sent to Claude.
123
+
124
+ **System Prompt Injection:**
125
+
126
+ ```text
127
+ [Original Device Persona]
128
+
129
+ IMPORTANT: You are currently acting as an API endpoint.
130
+ 1. You must respond ONLY with valid JSON.
131
+ 2. Do not include markdown formatting (like ```json).
132
+ 3. Do not include conversational filler before or after the JSON.
133
+ 4. Your response will be parsed by a machine.
134
+
135
+ Required JSON Structure:
136
+ {
137
+ "approved": boolean,
138
+ "reason": "string (short explanation)",
139
+ "metadata": { ...any relevant data... }
140
+ }
141
+ ```
142
+
143
+ **Retry Logic (Internal):**
144
+ If the LLM returns text with markdown ticks (e.g., \`\`\`json ... \`\`\`), the `voice-app` parser should aggressively strip them before `JSON.parse()`.
145
+
146
+ ---
147
+
148
+
149
+ ## 6. n8n Integration Guide
150
+
151
+ ### 6.1 HTTP Request Node Configuration
152
+
153
+ - **Method:** POST
154
+ - **URL:** `http://10.70.7.81:3000/api/query`
155
+ - **Body Parameters:**
156
+ ```json
157
+ {
158
+ "target": "Cephanie",
159
+ "query": "Is the server room temperature okay? It's currently 72F.",
160
+ "format": "json"
161
+ }
162
+ ```
163
+
164
+ ### 6.2 The IF Node (Conditional Logic)
165
+
166
+ Assuming the n8n HTTP Node outputs the API response, you can route based on the device's decision.
167
+
168
+ **Scenario:** "Ask Cephanie if I can reboot the server."
169
+
170
+ **Response Data (`body.response.data`):**
171
+ ```json
172
+ {
173
+ "approved": false,
174
+ "reason": "I am currently scrubbing the filesystem. Do not touch me.",
175
+ "metadata": { "job_id": "scrub_291" }
176
+ }
177
+ ```
178
+
179
+ **IF Node Expression:**
180
+
181
+ *Condition: Boolean*
182
+
183
+ ```javascript
184
+ // Expression to check approval
185
+ {{ $json.body.response.data.approved }}
186
+ ```
187
+
188
+ **Fallback Expression (Safety):**
189
+
190
+ If parsing fails, default to `false`:
191
+
192
+ ```javascript
193
+ {{ $json.body.response.data ? $json.body.response.data.approved : false }}
194
+ ```
195
+
196
+ ## 7. Example Scenarios
197
+
198
+ ### Scenario A: Simple Status Check (Text)
199
+ **Request:**
200
+ ```json
201
+ { "target": "Morpheus", "query": "Who is currently connected?", "format": "text" }
202
+ ```
203
+ **Response:**
204
+ ```json
205
+ {
206
+ "success": true,
207
+ "response": {
208
+ "raw": "Currently, only Agent Smith is connected via the neural link.",
209
+ "data": null,
210
+ "format": "text"
211
+ }
212
+ }
213
+ ```
214
+
215
+ ### Scenario B: Automation Decision (JSON)
216
+ **Request:**
217
+ ```json
218
+ {
219
+ "target": "Cephanie",
220
+ "query": "Alert: Disk usage at 90%. Should we expand volume?",
221
+ "format": "json"
222
+ }
223
+ ```
224
+ **Response:**
225
+ ```json
226
+ {
227
+ "success": true,
228
+ "response": {
229
+ "raw": "{\"approved\": true, \"reason\": \"90% is critical threshold. Expand immediately.\", \"metadata\": { \"urgent\": true }}",
230
+ "data": {
231
+ "approved": true,
232
+ "reason": "90% is critical threshold. Expand immediately.",
233
+ "metadata": { "urgent": true }
234
+ },
235
+ "format": "json"
236
+ }
237
+ }
238
+ ```
@@ -0,0 +1,176 @@
1
+ # Deployment
2
+
3
+ Running claude-phone-local beyond a first test. For the quickstart see
4
+ [../README.md](../README.md); for architecture see [../CLAUDE.md](../CLAUDE.md).
5
+
6
+ ## Topology
7
+
8
+ Two moving parts:
9
+
10
+ | Where | What | Why there |
11
+ |---|---|---|
12
+ | Container | drachtio, FreeSWITCH, voice-app, STT, TTS | one image, supervisord, all loopback |
13
+ | Host | `claude-api-server` → `claude` CLI | the CLI is installed and authenticated on the host |
14
+
15
+ They meet at exactly one place: `host.docker.internal:3333`.
16
+
17
+ ## Ports
18
+
19
+ | Port | Proto | Published | Purpose |
20
+ |---|---|---|---|
21
+ | 5070 | UDP/TCP | yes | drachtio SIP — the PBX talks to this |
22
+ | 30000–30100 | UDP | yes | RTP media |
23
+ | 3000 | TCP | yes | voice-app HTTP API |
24
+ | 5080 | UDP/TCP | **no** | FreeSWITCH SIP — internal only |
25
+ | 8021 | TCP | **no** | FreeSWITCH ESL — internal only |
26
+ | 9001 / 9002 | TCP | loopback | STT / TTS sidecars |
27
+ | 3333 | TCP | host | claude-api-server |
28
+
29
+ 5080 and 8021 are deliberately unpublished: drachtio reaches FreeSWITCH inside
30
+ the container. Publishing them would expose your media server to the LAN.
31
+
32
+ RTP is **30000–30100** because the 3CX SBC uses 20000–20099; overlapping the two
33
+ breaks audio.
34
+
35
+ ### Firewall
36
+
37
+ ```powershell
38
+ New-NetFirewallRule -DisplayName "claude-phone SIP" -Direction Inbound `
39
+ -Protocol UDP -LocalPort 5070 -Action Allow
40
+ New-NetFirewallRule -DisplayName "claude-phone RTP" -Direction Inbound `
41
+ -Protocol UDP -LocalPort 30000-30100 -Action Allow
42
+ ```
43
+
44
+ Do **not** open 3333. See [Security](#security).
45
+
46
+ ## Networking and NAT
47
+
48
+ Docker Desktop cannot do real host networking on Windows, so the compose file
49
+ uses a bridge with published ports. FreeSWITCH therefore cannot discover your
50
+ LAN address and must be told it.
51
+
52
+ The critical detail — SIP and RTP advertise **different** addresses:
53
+
54
+ ```
55
+ ext_rtp_ip = EXTERNAL_IP e.g. 172.16.14.225 (so the SBC reaches the media)
56
+ ext_sip_ip = container IP e.g. 172.18.0.2 (so drachtio reaches FreeSWITCH)
57
+ ```
58
+
59
+ `docker/entrypoint.sh` computes the container IP at boot. Verify after a deploy:
60
+
61
+ ```bash
62
+ docker logs claude-phone | grep "will advertise"
63
+ # [bootstrap] FreeSWITCH SIP will advertise 172.18.0.2; RTP will advertise 172.16.14.225
64
+ ```
65
+
66
+ FreeSWITCH's `nat.auto` ACL also has to be defused — it treats `172.16.0.0/12`
67
+ as local, which swallows both a `172.16.x` LAN and the `172.18.x` Docker bridge.
68
+ The Dockerfile patches `mrf.xml` with `local-network-acl = none` at build time.
69
+
70
+ ## Persistent state
71
+
72
+ Everything that must survive a restart is on the `./data` bind mount:
73
+
74
+ ```
75
+ data/voices/ Piper models (~195 MB)
76
+ data/models/ Whisper cache (~1.9 GB)
77
+ data/config/ devices.json (your SIP credentials)
78
+ data/audio/ generated speech (scratch, safe to clear)
79
+ ```
80
+
81
+ Back up `data/config/`. The rest re-downloads on its own.
82
+
83
+ A healthy restart is under a minute and re-downloads nothing:
84
+
85
+ ```
86
+ [bootstrap] voice en_US-lessac-medium already present
87
+ [bootstrap] whisper model cached
88
+ [bootstrap] devices.json already present (left untouched)
89
+ ```
90
+
91
+ ## Running the host wrapper as a service
92
+
93
+ `claude-api-server` must stay up or calls connect and then fail to answer.
94
+
95
+ **Windows (NSSM):**
96
+
97
+ ```powershell
98
+ nssm install claude-api "C:\Program Files\nodejs\node.exe" `
99
+ "C:\path\to\claude-api-server\server.js"
100
+ nssm set claude-api AppEnvironmentExtra CLAUDE_MODEL=claude-sonnet-5
101
+ nssm start claude-api
102
+ ```
103
+
104
+ **Linux (systemd):** `/etc/systemd/system/claude-api.service`
105
+
106
+ ```ini
107
+ [Unit]
108
+ Description=claude-phone API server
109
+ After=network.target
110
+
111
+ [Service]
112
+ Type=simple
113
+ User=youruser
114
+ WorkingDirectory=/path/to/claude-api-server
115
+ Environment=CLAUDE_MODEL=claude-sonnet-5
116
+ ExecStart=/usr/bin/node server.js
117
+ Restart=always
118
+
119
+ [Install]
120
+ WantedBy=multi-user.target
121
+ ```
122
+
123
+ It must run **as the user whose Claude Code is authenticated** — it inherits
124
+ that login and config.
125
+
126
+ ## Resource sizing
127
+
128
+ | | Idle | Per call |
129
+ |---|---|---|
130
+ | RAM | ~2.5 GB (Whisper `medium` resident) | +200 MB |
131
+ | CPU | negligible | 1–2 cores during transcription |
132
+ | Disk | ~2.2 GB in `./data` | a few MB of audio |
133
+
134
+ `WHISPER_MODEL` dominates memory: `small` ~1 GB, `medium` ~2.5 GB,
135
+ `large-v3` ~5 GB. Transcription is CPU-bound — it is the largest fixed part of
136
+ response latency.
137
+
138
+ ## Monitoring
139
+
140
+ ```bash
141
+ docker compose ps
142
+ docker logs claude-phone 2>&1 | grep -E "MULTI-REGISTRAR|CALL |error"
143
+ curl http://127.0.0.1:3000/api/devices # voice-app alive
144
+ curl http://127.0.0.1:3333/health # host wrapper alive
145
+ ```
146
+
147
+ Supervisord restarts any of the five processes that dies. Repeated restarts show
148
+ as `spawned:` lines without a matching `entered RUNNING state`.
149
+
150
+ SIP registration refreshes every ~27 minutes; losing it silently is the usual
151
+ cause of "calls stopped arriving".
152
+
153
+ ## Security
154
+
155
+ `claude-api-server` runs the CLI with `--dangerously-skip-permissions`: no
156
+ prompts, every tool, full access to the host as your user, with your normal
157
+ `~/.claude` config including MCP servers and plugins.
158
+
159
+ - **Bind 3333 to localhost.** Docker Desktop still reaches it through
160
+ `host.docker.internal`. On a LAN-exposed `0.0.0.0` bind, anyone who can route
161
+ to the port can execute commands as you.
162
+ - **Never commit** `data/config/devices.json` or `.env`.
163
+ - Consider a dedicated 3CX extension with restricted inbound routing so only
164
+ your own extensions can dial it.
165
+
166
+ ## Upgrading
167
+
168
+ ```bash
169
+ git pull
170
+ docker compose build
171
+ docker compose up -d
172
+ ```
173
+
174
+ `./data` is untouched, so no models are re-downloaded and `devices.json` is
175
+ preserved. Roll back with `docker compose -f docker-compose.multi.yml up -d`
176
+ (the previous five-container layout) if a build regresses.
@@ -0,0 +1,20 @@
1
+ FROM node:20-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies first (better caching)
6
+ COPY package*.json ./
7
+ RUN npm install --production
8
+
9
+ # Copy application code
10
+ COPY . .
11
+
12
+ # Create audio directory
13
+ RUN mkdir -p /app/audio
14
+
15
+ # HTTP API port
16
+ EXPOSE 3000
17
+ # WebSocket for audio streaming
18
+ EXPOSE 3001
19
+
20
+ CMD ["node", "index.js"]