Split into stateless MCP shim + daemon; fix Telnyx v5 SDK usage - #7
Open
sns45 wants to merge 1 commit into
Open
Conversation
The MCP server was a stateful daemon: one process held the webhook HTTP
server, the Baileys WhatsApp socket, all conversations, and the spawned
Claude processes, and it died with its stdio pipe. Every Claude Code
session started its own copy, so they fought over port 3333 and stole the
WhatsApp connection from each other.
src/index.ts is now a stateless shim that holds no state, binds no port,
answers tools/list from a static table, and forwards each call to the
daemon over loopback HTTP. src/daemon.ts owns everything long lived,
starts itself on first tool call, and outlives the session so inbound
calls and messages still arrive. New CLI: --status, --stop, --daemon,
--pair.
Stray data/ directories
baileysAuthDir defaulted to the relative path "data/baileys-auth", which
resolved against whatever project Claude Code was launched from, so every
project got a data/ folder. State now lives in ~/.better-call-claude
(honours XDG_STATE_HOME, override with BETTERCALLCLAUDE_STATE_DIR) via a
new paths module that cannot produce a relative path. An existing
./data/baileys-auth is copied, never moved, so the WhatsApp pairing
survives the upgrade.
Telnyx
telnyx@5 is a ground up rewrite and every call site failed to typecheck:
new Telnyx(apiKeyString) is now new Telnyx({apiKey}), calls.create is
calls.dial, and speak/gather_using_speak/hangup moved to calls.actions.*
with the id as first argument. Worse, the webhook handlers returned TwiML
to Telnyx, which ignores response bodies, so Telnyx voice never worked.
Replaced with a real Call Control event router. Interim transcripts are
ignored so a task is not spawned from half a sentence, and outbound
greetings are queued until call.answered because dial cannot carry one
(previously the greeting was silently dropped).
Security
/api/* was unauthenticated while a Tailscale Funnel published the whole
server, so anyone with the URL could place calls and send messages as the
user. Now bearer token protected (0600 token in the state dir), bound to
127.0.0.1, and /health no longer leaks message content unauthenticated.
Telnyx webhook verification was a stub returning true after a timestamp
check; it now uses the SDK's Ed25519 verifier and defaults on whenever a
key is present.
Other fixes
* The published bundle contained `var {serve} = globalThis.Bun` while
engines claimed node >= 18, so npm installs crashed on Node. Serving is
now runtime agnostic.
* Phone normalization prefixed +1 to any number without a +, corrupting
international numbers (919876543210 became +1919876543210).
* Baileys reconnect recursed without tearing down the old socket, piling
up listeners. Now single flight with capped backoff.
* receive_inbound_message with channel "any" could consume a voice call
and then report that no message arrived.
* /api/call never recorded the provider call id, so every later speak or
hangup on that conversation targeted nothing.
* MCP advertised version 2.0.0 while the package was 3.1.3.
* get_call_status was documented but did not exist.
* Tailscale is optional and no longer interactive: it never runs
tailscale up, opens the app, or waits on a browser. Added
BETTERCALLCLAUDE_PUBLIC_URL for a self managed tunnel.
* bun test matched the vendored servers/ suite and reported its failures
as ours; scoped via bunfig.toml.
* tools.json is generated from src/tools.ts (bun run tools:sync) with a
test that fails on drift.
Verified locally: tsc clean, 303 tests pass, build works, and the built
package runs end to end under both Bun and Node. Live Telnyx and Twilio
call flows are covered by unit tests against a mocked SDK rather than
real accounts.
sns45
force-pushed
the
fix/stateless-mcp-daemon-telnyx-v5
branch
from
August 28, 2026 00:02
547ca3b to
145220d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Coming back to this project after a while, three things were broken. All three are fixed here.
1. Every project got a stray
data/folderbaileysAuthDirdefaulted to the relative path"data/baileys-auth", which resolved against whatever directory Claude Code launched the MCP server from. So opening any project created adata/folder in it.State now lives in
~/.better-call-claude/(honours$XDG_STATE_HOME, override withBETTERCALLCLAUDE_STATE_DIR), resolved through a newsrc/paths.tsthat cannot produce a relative path. An existing./data/baileys-authis copied, never moved, so the WhatsApp pairing survives the upgrade.2. MCP is stateless, the server was not
The MCP server was itself the stateful daemon: one process held the webhook HTTP server, the Baileys WhatsApp socket, every conversation, and the spawned Claude processes, and it died with its stdio pipe. Each Claude Code session started its own copy, so sessions fought over port 3333 and stole the WhatsApp connection from one another.
src/index.tsis now a shim that holds no state, binds no port, answerstools/listfrom a static table, and forwards each call to the daemon.src/daemon.tsowns everything long lived, starts itself on first tool call, and outlives the session so inbound calls and messages still arrive. New CLI:--status,--stop,--daemon,--pair.3. Telnyx usage was broken, not merely dated
telnyx@5is a ground up rewrite and every call site failed to typecheck:new Telnyx(apiKeyString)new Telnyx({ apiKey })calls.create(...)calls.dial(...)calls.speak({call_control_id, ...})calls.actions.speak(id, {...})calls.gather_using_speak(...)calls.actions.gatherUsingSpeak(id, {...})calls.hangup({call_control_id})calls.actions.hangup(id, {})call.answeredbecausedialcannot carry one (the greeting was previously dropped silently).Security
/api/*was unauthenticated while a Tailscale Funnel published the whole server, so anyone with the URL could place calls and send messages as the user. Now bearer token protected (0600token in the state dir), bound to127.0.0.1, and/healthno longer leaks message content unauthenticated.trueafter checking only the timestamp. It now uses the SDK's Ed25519 verifier and defaults on whenever a key is present.Other bugs found and fixed
var {serve} = globalThis.Bunwhileenginesclaimednode >= 18, so npm installs crashed immediately on Node. Serving is now runtime agnostic.+1to any number without a+, corrupting international numbers (919876543210became+1919876543210).receive_inbound_messagewithchannel: "any"could consume a voice call and then report that no message arrived./api/callnever recorded the provider call id, so every later speak or hangup on that conversation targeted nothing.2.0.0while the package was3.1.3.get_call_statuswas documented in the README but did not exist.tailscale up, opens the app, or waits on a browser, which a background daemon should not do. AddedBETTERCALLCLAUDE_PUBLIC_URLfor a self managed tunnel.bun testmatched the vendoredservers/suite and reported its 13 failures as ours; scoped viabunfig.toml.tools.jsonis now generated fromsrc/tools.ts(bun run tools:sync) with a test that fails on drift.Verification
tsc --noEmitclean (it previously reported 7 errors, all Telnyx).Not verified: live Telnyx and Twilio call flows, since there are no accounts on them. Those paths are covered by unit tests against a mocked SDK and checked against the SDK's typed surface, not against the real API. WhatsApp pairing also needs a live QR scan.