@telnyx/eva-adapter
v0.1.0
Published
EVA server-native Telnyx media adapter: bridges EVA audio/control to Telnyx Call Control + bidirectional Media Streaming + hosted AI Assistant.
Keywords
Readme
@telnyx/eva-adapter
Server-native Telnyx media adapter for EVA. A standalone Node.js / TypeScript service that lets an EVA provider wrapper drive a Telnyx AI Assistant call over a Twilio-style media WebSocket — no browser, WebRTC, or Chromium runtime required.
The adapter speaks two protocols:
- EVA side — a local
ws://{host}:{port}/wsendpoint that accepts Twilio Media Streams-style events (start/media/user_speech_start/user_speech_stop/stop) and streams assistant audio back asmediaframes. - Telnyx side — Call Control REST, a public bidirectional Media Streaming
WebSocket (
/telnyx-media), and a webhook receiver (/telnyx-webhooks).
It bridges μ-law (PCMU, 8 kHz, mono, 160-byte / 20 ms frames) pass-through in both directions, paces playback at 20 ms, and writes the proof artifacts EVA benchmarks need.
Implements VSDK-277. Design source of truth:
docs/eva-server-native-telnyx-media-adapter.md(branchfeat/eva-local-streaminteam-telnyx/telnyx-voice-ai-lib).
Architecture
EVA provider wrapper
⇄ ws://localhost:{adapterPort}/ws # Twilio-style media (EVA side)
⇄ TelnyxEvaNodeAdapter
→ Telnyx Call Control REST # dial/answer, streaming_start, ai_assistant_start
⇄ wss://{public}/telnyx-media # bidirectional Media Streaming (RTP, PCMU)
← https://{public}/telnyx-webhooks # call.answered, streaming.started, …Session lifecycle, driven by the EVA start event:
EVA start → dial/answer → call.answered
→ streaming_start → media WS connected/start → streaming.started
→ ai_assistant_start (→ conversation id)
→ bidirectional audio bridge
→ cleanup (stop assistant, stop stream, hangup, close sockets, finalize artifacts)Install & build
Requires Node 20+. Uses npm + TypeScript (ESM, NodeNext) and vitest.
npm ci
npm run build # tsc -p tsconfig.build.json → dist/
npm run typecheck # tsc --noEmit (src + tests)
npm run lint # eslint .
npm test # vitest run (unit + fake-Telnyx integration)This package pins the EVA-side beta releases:
@telnyx/[email protected](types/helpers only — consumed from npm)@telnyx/[email protected]@telnyx/[email protected]
Configuration
Copy .env.example and fill in your Telnyx credentials. All variables:
| Variable | Required | Default | Notes |
| ------------------------------- | -------- | ------------------ | ---------------------------------------------------- |
| TELNYX_API_KEY | yes | — | Telnyx API key |
| TELNYX_CONNECTION_ID | yes | — | Call Control connection id |
| TELNYX_AI_ASSISTANT_ID | yes | — | Hosted AI Assistant id |
| TELNYX_FROM_NUMBER | yes | — | Caller (from) number |
| TELNYX_TO_NUMBER | yes | — | Callee (to) number |
| TELNYX_PUBLIC_MEDIA_WS_URL | yes | — | Public wss://…/telnyx-media Telnyx connects to |
| TELNYX_PUBLIC_WEBHOOK_URL | yes | — | Public https://…/telnyx-webhooks |
| TELNYX_STREAM_AUTH_TOKEN | rec. | — | Rejects unauthenticated media upgrades |
| TELNYX_WEBHOOK_SIGNING_SECRET | rec. | — | HMAC-SHA256 webhook verification |
| TELNYX_EVA_ADAPTER_PORT | no | 8090 | Local EVA /ws port |
| TELNYX_EVA_BIND_HOST | no | 127.0.0.1 | EVA /ws bind host |
| TELNYX_SETUP_MODE | no | outbound | outbound | inbound |
| TELNYX_STREAM_TRACK | no | both_tracks | inbound_track | outbound_track | both_tracks |
| TELNYX_EVA_OUTPUT_DIR | no | ./eva-output | Artifact directory |
| TELNYX_ENVIRONMENT | no | production | |
| TELNYX_EVA_LOG_LEVEL | no | info | debug|info|warn|error|silent |
| TELNYX_TIMEOUT_*_MS | no | see .env.example | per-milestone hard timeouts |
The public media + webhook URLs must point at this same process and be reachable by Telnyx. For local development use a tunnel (ngrok / cloudflared) or a deployed staging adapter.
Running
cp .env.example .env # then edit
npm run build
node dist/cli.js # or: npm startThe process starts the EVA /ws server and the public media+webhook server, then waits
for the EVA wrapper to connect and send start.
EVA integration
The EVA provider wrapper owns only the thin EVA-specific layer. It should:
- Launch / reach this adapter and connect to
adapter_local_ws_url(ws://localhost:8090/ws). - Send a Twilio-style
start, then stream caller audio asmediaevents with base64 μ-law (8 kHz, 160 bytes / 20 ms). - Emit
user_speech_start/user_speech_stopmarkers so the adapter can computemodel_responselatency. - Receive assistant audio back as
mediaevents and play it to EVA. - Send
stopto end the session.
Suggested EVA s2s_params:
{
"provider": "telnyx",
"agent_id": "assistant-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"connection_id": "7267xxxxxxxxxxxxxx",
"from": "+15555550100",
"to": "+15555550199",
"adapter_local_ws_url": "ws://localhost:8090/ws",
"public_media_ws_url": "wss://adapter.example.com/telnyx-media",
"public_webhook_url": "https://adapter.example.com/telnyx-webhooks",
"stream_codec": "PCMU",
"stream_sample_rate": 8000,
"stream_track": "both_tracks",
"environment": "production"
}Only one EVA conversation is served per process; extra connections are rejected with
WebSocket close code 1013.
Artifacts
On finalize the adapter writes, under TELNYX_EVA_OUTPUT_DIR:
audio_user.wav— caller (EVA → Telnyx) audioaudio_assistant.wav— assistant (Telnyx → EVA) audioaudio_mixed.wav— combined streamtranscript.jsonl— transcript entries ({role, content, timestamp})metrics.json— latency/quality metricsaudit_log.json— ordered lifecycle audit eventsadapter.log— framework log
metrics.json includes model_response latency, computed as
firstAssistantAudioAt − latestUserSpeechStopAt (first non-silent assistant frame after
the latest user_speech_stop).
Proof criteria (VSDK-277)
A run is considered a pass when all of:
call.answeredobserved.- Media WebSocket receives
connectedthenstart. streaming.startedwebhook observed.ai_assistant_startreturns success and a conversation id.- The injected phrase reaches the hosted assistant (visible in transcript / insights, or the assistant clearly reacts).
- Assistant audio streams back;
audio_assistant.wavis non-empty and time-aligned. model_responselatency is computable fromuser_speech_stop→ first assistant audio.
Critical risk: injected bidirectional media may not reach hosted AI Assistant STT (it may only play to the participant). If, after controlled retries, injected audio is not consumed by assistant STT — or assistant audio can't be isolated — fall back to the Node WebRTC bridge variant described in the design doc, keeping the same EVA wrapper contract.
Project layout
src/
audio/ μ-law codec, frame repacketization, WAV writer, 20ms pacer
protocol/ EVA, Telnyx media, and client-state message types/parsers
telnyx-call-control-client.ts Call Control REST (dial/stream/assistant/cleanup)
telnyx-webhook-receiver.ts webhook parse + signature verify + waitFor()
telnyx-media-endpoint.ts public media WS (noServer; host wires upgrades)
public-server.ts single HTTP server: media upgrades + webhooks + /health
eva-server.ts EVA-facing /ws server
audio-bridge.ts bidirectional bridge + WAV capture + reorder buffer
artifacts.ts audit/transcript/metrics writers
telnyx-eva-node-adapter.ts lifecycle orchestrator
config.ts logger.ts index.ts cli.ts
tests/ unit tests + fake-Telnyx full-lifecycle integration testSecurity & reliability
- Authenticate media upgrades with
stream_auth_token; reject otherwise. - Verify Telnyx webhook signatures before trusting payloads.
- Correlate by
call_control_id,call_session_id, andclient_state; uniquecommand_idper command for idempotency. - API keys, bearer tokens, and phone numbers are redacted in logs.
- Hard timeouts on every milestone; best-effort cleanup always runs (stop assistant, stop stream, hangup, close sockets, finalize artifacts).
- Audio artifacts stay local to the configured output dir.
Scope
This is the server-native first implementation: no React, browser, WebRTC, or
Playwright/Chromium runtime dependencies. The pinned @telnyx/* beta packages are declared
for EVA-side compatibility; transcript output mirrors @telnyx/ai-agent-lib's
TranscriptItem shape (toTranscriptEntry) without coupling to its React-oriented,
extensionless ESM type barrel (which does not resolve cleanly under this package's NodeNext
module resolution).
