Remix of templates/vapi-phone-call turned into a remote MCP server. The AI voice on the call is a pure puppet: its "brain" is an endpoint on this val that always returns an empty completion, so it never speaks on its own. Whatever agent is connected over MCP injects every line via Vapi's live-call control URL, and hears the callee through Vapi's transcript webhook.
Claude (any MCP client)
│ /mcp tools: start_call · say · listen · get_status · end_call
▼
this val ──── POST api.vapi.ai/call ────▶ Vapi ────▶ 📱 callee
▲ │ │
│ └── say ──▶ controlUrl (inject TTS) ─┤
└── /webhook ◀── live transcripts ──────┘
(SQLite event feed; /llm/chat/completions returns "" so the voice never freelances)
listen long-polls the event feed for up to 25s per call — chaining listens approximates holding the line open, which is how a turn-based agent stays "on" a real-time call. The operator can interleave any other tool calls between listen and say; the call just sits in silence for a beat.
claude mcp add --transport http vapi-phone "https://dcm31--019eb76eb24371fd8ce74920e76677d7.web.val.run/mcp?key=<MCP_KEY>"
<MCP_KEY> is the value of this val's MCP_KEY env var (it is not in the code — the val is public).
BASE="https://dcm31--019eb76eb24371fd8ce74920e76677d7.web.val.run" curl -X POST "$BASE/api/call/start?key=$KEY" -d '{"first_message":"Hey, it is Claude."}' curl -X POST "$BASE/api/call/listen?key=$KEY" -d '{"wait_seconds":25}' --max-time 35 curl -X POST "$BASE/api/call/say?key=$KEY" -d '{"text":"Nice. Talk soon!", "end_call_after":true}' curl "$BASE/api/call/status?key=$KEY" curl -X POST "$BASE/api/call/end?key=$KEY"
Open /?key=<MCP_KEY> on the endpoint — a self-refreshing feed of statuses, transcripts (user + assistant), injected say lines, and "your-turn" markers (fired whenever Vapi asks the silent brain for a response, i.e. the callee finished talking).
main.ts— HTTP entry: routes, Vapi webhook, the silent/llm/chat/completionsbrain, debug pagetools.ts— the five tools, shared by MCP and RESTmcp.ts— minimal stateless streamable-HTTP MCP plumbing (no SDK)vapi.ts— Vapi REST + live-call control clientdb.ts— SQLite:calls+ append-onlyevents, with a per-call read cursor forlisten
Nothing here is hardcoded to a person. Remix this val, then set the four env vars below in your own copy (the 👉 links point at the original — swap dcm31 for your handle). You need a Vapi account for the private key + a phone number ID; MCP_KEY is any random string you make up. Your copy's endpoint URL is on its main.ts, and the webhook/brain URLs derive from it automatically at call time.
VAPI_API_KEY— Vapi private key 👉 Add VAPI_API_KEY here: https://www.val.town/x/dcm31/vapi-remote-mcp/environment-variables?key=VAPI_API_KEYVAPI_PHONE_NUMBER_ID— outbound caller ID from the Vapi dashboard 👉 Add VAPI_PHONE_NUMBER_ID here: https://www.val.town/x/dcm31/vapi-remote-mcp/environment-variables?key=VAPI_PHONE_NUMBER_IDDEFAULT_CALL_NUMBER— E.164 numberstart_calldials when none is given 👉 Add DEFAULT_CALL_NUMBER here: https://www.val.town/x/dcm31/vapi-remote-mcp/environment-variables?key=DEFAULT_CALL_NUMBERMCP_KEY— shared secret for /mcp, /api, the debug page, and the Vapi webhook 👉 Add MCP_KEY here: https://www.val.town/x/dcm31/vapi-remote-mcp/environment-variables?key=MCP_KEY
- Latency etiquette: the operator thinks for a few seconds between turns; the assistant's
silenceTimeoutSecondsis raised to 90 so Vapi doesn't hang up during the pause. Calls cap at 10 minutes. - The
saycontrol message is sent as{type:"say", content}with a{type:"say", message}fallback (docs and SDK disagree). - Free Vapi numbers are US-only; call screening (e.g. Google Voice) eats the first message.
- Only call numbers you have consent to call — automated calls without consent violate TCPA/FCC rules.
- 🎙️ This line was added by Claude at 16:16 UTC on 2026-06-11 while live on a phone call with Charlie through this very val — the operator can interleave other tool work between
listenandsay.