Skip to content

feat(sinks): add Telnyx PSTN sink for outbound call playback - #1043

Open
a692570 wants to merge 3 commits into
jamiepine:mainfrom
a692570:feat/telnyx-sink
Open

feat(sinks): add Telnyx PSTN sink for outbound call playback#1043
a692570 wants to merge 3 commits into
jamiepine:mainfrom
a692570:feat/telnyx-sink

Conversation

@a692570

@a692570 a692570 commented Aug 13, 2026

Copy link
Copy Markdown

Add Telnyx PSTN sink: call any phone and play generations on the line

What this adds

Voicebox today runs the full voice I/O loop locally (clone, generate, dictate,
agent speech) but stops at the speaker. There's no way to take a generation and
play it on a real phone call. The roadmap lists "Pipeline routing … webhook +
MCP sinks" and "Platform sinks: Apple Notes, Obsidian, and other opt-in
integrations."

This PR ships the first reference sink, Telnyx, and the convention future sinks
follow. Generation stays local; only the PSTN leg touches the carrier.

What's in the box

  • voicebox.call MCP tool: dial a number and play a generation on the call.
    Mirrors voicebox.speak's shape (profile resolution, personality rewrite,
    engine override).
  • POST /call: REST wrapper, same pattern as POST /speak.
  • POST /sinks/telnyx/webhook: receives Telnyx call.answered and
    playback.ended. Per-call webhook secret stops third-party triggering.
  • GET/PUT /sinks/telnyx: settings CRUD (api_key masked in responses,
    plus a missing array naming any unset required field).
  • GET /sinks/telnyx/health: verify the API key against Telnyx.
  • TelnyxSettings and TelnyxCalls SQLite tables: created idempotently on
    first boot via the existing migration pattern.
  • Docs: new sinks.mdx page in the overview.
  • Tests: backend/tests/test_telnyx_sink.py, 17 cases.

Flow

Caller → POST /call (or voicebox.call)
  → Voicebox resolves profile + generation (existing or fresh via /generate)
  → Voicebox POST /v2/calls to Telnyx with connection_id + webhook_url + per-call secret
  → Telnyx returns call_control_id immediately
  → Voicebox persists TelnyxCall row, returns to caller (status="dialing")

Callee picks up → Telnyx POST /sinks/telnyx/webhook (call.answered)
  → Voicebox verifies webhook_secret, claims the row, acks immediately
  → background task waits for the generation to finish (if still "generating")
  → POST /v2/calls/{id}/actions/playback_start with audio_url
  → Telnyx fetches {public_base_url}/audio/{generation_id} and plays it

If auto_hangup → Telnyx POST /sinks/telnyx/webhook (playback.ended)
  → Voicebox POST /v2/calls/{id}/actions/hangup

The caller does not block on the callee picking up. The MCP/REST call returns as
soon as Telnyx accepts the dial; playback happens asynchronously via the webhook.

Telnyx wants a 2xx within 2000 ms and retries otherwise, and it queues
consecutive play commands, so the webhook acknowledges before doing any slow
work and claims the call row so a retried delivery can't start a second
playback on the same call.

HTTP-only, no SDK

Uses httpx against api.telnyx.com/v2. Three endpoints: POST /calls,
POST /calls/{id}/actions/playback_start, POST /calls/{id}/actions/hangup.
No telnyx package, no PyInstaller bundling changes.

httpx was already imported at module scope by services/cloud.py but was
never declared, so this PR adds it to requirements.txt.

Config

  • Settings → Sinks → Telnyx: API key, Call Control Application ID
    (connection_id), from-number, public_base_url (ngrok/Funnel URL), default
    voice profile, auto-hangup toggle, and an enabled master switch.
  • Per-sink config, not global env, matching the pattern Apple Notes / Obsidian
    sinks will need.

Security note on public_base_url

Telnyx has to reach this server, but Voicebox binds to 127.0.0.1 and has no
request authentication. A bare ngrok http 17493 therefore publishes every
route, including the profile, generation, and settings APIs. The docs call this
out and tell users to front the tunnel with a proxy that allows only
POST /sinks/telnyx/webhook and GET /audio/.

Worth a maintainer opinion: if sinks become a common pattern, Voicebox may want
a first-class "expose these paths only" mode rather than leaving each user to
build it.

What this PR does NOT do

  • No inbound calls (separate sink, separate PR).
  • No streaming audio over the call. Uses playback_start with an audio URL.
    No SIPREC, no bidirectional audio.
  • No Telnyx SDK.
  • No move of TTS to the cloud. Generation stays local.
  • No frontend UI changes. The backend surface (REST + MCP) is complete; the
    "Call" button on the generation card is a small follow-up PR, and the docs
    say so rather than describing a button that isn't there.
  • No Telnyx webhook signature verification yet. See open question 1.

Files

  • backend/database/models.py: TelnyxSettings + TelnyxCall ORM
  • backend/database/__init__.py: re-exports
  • backend/database/migrations.py: _migrate_telnyx_settings,
    _migrate_telnyx_calls (idempotent CREATE TABLE, plus a connection_id
    backfill for anyone who ran an earlier build of this branch)
  • backend/models.py: CallRequest, CallResponse, TelnyxSettingsResponse,
    TelnyxSettingsUpdate Pydantic models
  • backend/services/sinks.py: TelnyxClient (thin httpx wrapper) +
    dial_and_play, handle_telnyx_webhook, place_call orchestration
  • backend/routes/sinks.py: POST /call, settings CRUD, webhook receiver
  • backend/routes/__init__.py: register the new router
  • backend/mcp_server/tools.py: voicebox.call tool, next to voicebox.speak
  • backend/tests/test_telnyx_sink.py: wire contract, config gate, webhook gate
  • requirements.txt: declare httpx
  • docs/content/docs/overview/sinks.mdx: setup + usage guide
  • docs/content/docs/overview/meta.json: add the new page

Why Telnyx

  • Carrier-grade PSTN, sub-second call setup, global reach.
  • Pay-per-minute, no monthly minimums, fits a hobbyist OSS install.
  • REST + webhooks, no SIP stack required on the client.
  • A user with a Telnyx account and a $1 number can dial out in a few minutes.

Setup

  1. Sign up at telnyx.com, grab an API key, buy a number (~$1/mo), create a Call
    Control Application, and assign the number to it. Copy the application ID.
  2. Expose Voicebox via ngrok, Tailscale Funnel, or Cloudflare Tunnel, ideally
    behind a proxy restricted to the two paths above. Copy the HTTPS URL.
  3. Voicebox → Settings → Sinks → Telnyx: paste the key, the application ID, the
    from-number, the public URL, pick a default voice profile, turn on enabled.
  4. Hit "Test connection".
  5. Test via MCP:
    await voicebox.call({
      "to": "+15551234567",
      "profile": "Morgan",
      "text": "Deploy complete."
    })
    Or via curl:
    curl -X POST http://127.0.0.1:17493/call \
      -H "Content-Type: application/json" \
      -d '{"to":"+15551234567","profile":"Morgan","text":"Deploy complete."}'

Verification

  • backend/tests/test_telnyx_sink.py passes (17 cases). Covers the request
    bodies sent to Telnyx, the config gate, constant-time token comparison, and
    single-fire playback under retried webhook delivery.
  • Unconfigured sink: POST /call returns 400 naming the missing fields; the
    MCP tool raises with the same message.
  • Existing routes unaffected; the new router is additive.
  • Live PSTN verification against a real number is pending on this revision.
    An earlier revision of this branch sent no connection_id and used a
    media_url field that doesn't exist on playback_start, so both the dial
    and the playback would have been rejected by the API. That's fixed here and
    covered by tests, but I want to re-run it end to end on hardware before
    anyone treats the call path as confirmed working.

Open questions for maintainers

  1. Webhook signature verification. This PR gates the webhook on a per-call
    webhook_secret query param, compared in constant time, failing closed when
    absent. Telnyx also signs every event with telnyx-signature-ed25519 and
    telnyx-timestamp over {timestamp}|{raw body}, verifiable against the
    account public key. That authenticates the sender rather than just scoping
    the URL, and it's the better gate for an internet-exposed endpoint, but it
    needs an Ed25519 dependency and a public-key setting. Want it in this PR, or
    as a follow-up?
  2. Sink vs. pipeline-routing roadmap. This ships a single sink with its own
    settings table, additive, not blocking on the broader pipeline-routing
    refactor. When that lands, the Telnyx sink can be reframed as a sink node
    and the HTTP + webhook logic carries over. Merge the additive version now
    and migrate later, or hold until the sink interface is settled?
  3. Frontend "Call" button. Backend surface is complete; the React UI is
    left for a follow-up so this PR stays reviewable, and the docs mark it as
    not yet shipped. Acceptable, or should the UI ship here?

License

MIT, no CLA friction. The contributor is a Telnyx employee but is contributing
as an individual user of the project. No Telnyx affiliation claim beyond the
choice of carrier.

Summary by CodeRabbit

  • New Features

    • Added optional Telnyx phone calling with generated voice playback.
    • Added REST and MCP controls for text or existing generations.
    • Added configuration, health checks, call tracking, webhooks, status updates, and automatic hangup support.
    • Added voice profile, language, personality, caller, and masked credential settings.
    • Added validation and retry-safe call event handling.
  • Documentation

    • Added setup, configuration, usage, security, limitations, and troubleshooting guidance for Telnyx calling.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ca5d3827-eda1-4a55-be0c-607b383933b8

📥 Commits

Reviewing files that changed from the base of the PR and between a6c8df0 and b0021ed.

📒 Files selected for processing (2)
  • backend/services/sinks.py
  • docs/content/docs/overview/sinks.mdx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/services/sinks.py

📝 Walkthrough

Walkthrough

Adds an opt-in Telnyx outbound calling sink with persisted settings, call tracking, asynchronous generation playback, webhook handling, REST and MCP interfaces, tests, and setup documentation.

Changes

Telnyx outbound calling sink

Layer / File(s) Summary
Telnyx persistence and request contracts
backend/database/..., backend/models.py
Adds singleton Telnyx settings, outbound call records, idempotent migrations, public database exports, and validated request and response models.
Telnyx call and webhook service
backend/services/sinks.py, backend/tests/test_telnyx_sink.py, requirements.txt
Adds Telnyx API calls, generation polling, per-call webhook tokens, playback, lifecycle tracking, optional automatic hangup, HTTP client support, and tests for wire contracts, configuration, masking, and webhook retry handling.
REST, MCP, and router wiring
backend/routes/..., backend/mcp_server/tools.py
Adds settings, health, call, and webhook routes. Registers the router and adds the voicebox.call MCP tool.
Telnyx sink documentation
docs/content/docs/overview/meta.json, docs/content/docs/overview/sinks.mdx
Documents setup, configuration, call initiation, asynchronous call control, constraints, and troubleshooting.

Estimated code review effort: 4 (Complex) | ~45 minutes

Mergeability Score: 🟡 Moderate · up to b0021

Outbound calls may fail when call records are persisted, invalid phone numbers may produce opaque carrier errors, and webhook failures may leave calls stuck without clear recovery; merge should wait for these bounded correctness and reliability issues to be fixed or explicitly accepted.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Voicebox
  participant Telnyx
  participant Webhook
  participant Generation
  Client->>Voicebox: Submit text or generation_id
  Voicebox->>Generation: Create or validate generation
  Voicebox->>Telnyx: Create outbound call
  Telnyx->>Webhook: Send answered event
  Webhook->>Generation: Wait for completed audio
  Webhook->>Telnyx: Start audio playback
  Telnyx->>Webhook: Send playback-ended event
  Webhook->>Telnyx: Hang up when enabled
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the addition of the Telnyx PSTN sink for outbound call playback, which is the main change.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (5)
backend/routes/sinks.py (2)

168-169: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Chain the original exception.

Add from exc so the ValueError traceback survives in logs. Ruff reports this as B904.

♻️ Proposed refactor
     except ValueError as exc:
-        raise HTTPException(status_code=400, detail=str(exc))
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/routes/sinks.py` around lines 168 - 169, Update the ValueError
handler in the surrounding route function to chain the original exception when
raising HTTPException, preserving exc as the explicit cause while keeping the
existing status code and detail unchanged.

Source: Linters/SAST tools


42-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the response builder.

The TelnyxSettingsResponse construction is identical in get_telnyx_settings and update_telnyx_settings. Extract a small helper so a new settings field needs one edit.

♻️ Proposed refactor
+def _settings_response(row) -> models.TelnyxSettingsResponse:
+    return models.TelnyxSettingsResponse(
+        enabled=bool(row.enabled),
+        api_key_masked=sinks_service.mask_key(row.api_key),
+        api_key_set=bool(row.api_key),
+        from_number=row.from_number,
+        public_base_url=row.public_base_url,
+        default_profile_id=row.default_profile_id,
+        auto_hangup=bool(row.auto_hangup),
+    )

Then return _settings_response(row) from both endpoints.

Also applies to: 70-78

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/routes/sinks.py` around lines 42 - 50, Extract the duplicated
TelnyxSettingsResponse construction into a shared _settings_response(row)
helper, preserving all existing field mappings and conversions. Update both
get_telnyx_settings and update_telnyx_settings to return _settings_response(row)
so future settings fields require one change.
backend/mcp_server/tools.py (2)

322-336: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the no-op exception wrapper.

The except ValueError block re-raises a new ValueError with the same message. It changes nothing for the caller. Remove it and let the original exception propagate.

♻️ Proposed refactor
-            try:
-                result = await sinks_service.place_call(
-                    to=to,
-                    text=text,
-                    generation_id=generation_id,
-                    profile_id=vp.id,
-                    profile_name=vp.name,
-                    engine=engine,
-                    language=language,
-                    personality=use_persona,
-                    auto_hangup=auto_hangup,
-                    db=db,
-                )
-            except ValueError as exc:
-                raise ValueError(str(exc)) from exc
+            result = await sinks_service.place_call(
+                to=to,
+                text=text,
+                generation_id=generation_id,
+                profile_id=vp.id,
+                profile_name=vp.name,
+                engine=engine,
+                language=language,
+                personality=use_persona,
+                auto_hangup=auto_hangup,
+                db=db,
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools.py` around lines 322 - 336, Remove the redundant
ValueError try/except wrapper around the sinks_service.place_call invocation in
the tool handler, allowing the original exception to propagate unchanged while
preserving the existing call arguments and result handling.

289-319: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Fetch the client binding once.

The code queries MCPClientBinding for the same client_id twice: once for the profile and once for the personality default. Load it once and reuse the row. This also removes the nested if that Ruff reports as SIM102.

♻️ Proposed refactor
+            binding = None
+            if client_id:
+                binding = (
+                    db.query(MCPClientBinding)
+                    .filter(MCPClientBinding.client_id == client_id)
+                    .first()
+                )
+
             if vp is None:
                 # Per-client binding next.
-                if client_id:
-                    binding = (
-                        db.query(MCPClientBinding)
-                        .filter(MCPClientBinding.client_id == client_id)
-                        .first()
-                    )
-                    if binding and binding.profile_id:
-                        vp = resolve_profile(binding.profile_id, client_id, db)
+                if binding and binding.profile_id:
+                    vp = resolve_profile(binding.profile_id, client_id, db)
             if vp is None:
                 # Sink default last.
                 s = sinks_service.get_settings(db)
                 if s.default_profile_id:
                     vp = resolve_profile(s.default_profile_id, client_id, db)
             if vp is None:
                 raise ValueError(
                     "No voice profile resolved. Pass `profile=`, set a "
                     "default in Voicebox → Settings → Sinks → Telnyx, or "
                     "bind a profile to this client in Settings → MCP."
                 )
 
             resolved_personality = personality
-            if resolved_personality is None and client_id:
-                binding = (
-                    db.query(MCPClientBinding)
-                    .filter(MCPClientBinding.client_id == client_id)
-                    .first()
-                )
-                if binding is not None:
-                    resolved_personality = bool(binding.default_personality)
+            if resolved_personality is None and binding is not None:
+                resolved_personality = bool(binding.default_personality)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools.py` around lines 289 - 319, The profile and
personality resolution flow queries MCPClientBinding twice for the same
client_id. Fetch the binding once before the profile fallback logic, reuse it
for both profile and personality resolution, and simplify the personality
condition to avoid the nested if flagged by Ruff SIM102.

Source: Linters/SAST tools

backend/services/sinks.py (1)

224-224: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Use datetime.UTC for both timestamps. Ruff reports UP017 at backend/services/sinks.py#L224 and #L336. Remove the unused timezone import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/sinks.py` at line 224, Update both timestamp constructions
in backend/services/sinks.py at lines 224 and 336 to use datetime.UTC instead of
timezone.utc, and remove the now-unused timezone import.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/mcp_server/tools.py`:
- Around line 238-247: Update voicebox_call to validate to against the same
E.164 pattern enforced by CallRequest before invoking place_call. Reject invalid
values with a clear ValueError correction message, and leave the existing call
flow unchanged for valid numbers.

In `@backend/models.py`:
- Around line 917-921: Update the public_base_url field validation in the model
containing public_base_url to accept only valid HTTPS URLs, while allowing None
for unset configuration. Reject HTTP URLs and non-URL strings during settings
validation, preserving the existing 200-character limit and description.

In `@backend/routes/sinks.py`:
- Around line 140-144: Update the profile-resolution flow in the endpoint so
omitted profiles first resolve the per-client MCP binding using the request’s
client identifier, before falling back to the sink’s default profile. Reuse the
existing binding-aware resolution behavior from backend/mcp_server/tools.py and
ensure explicit profiles retain precedence.

In `@backend/services/sinks.py`:
- Around line 427-434: Align the call status contract across all affected sites
by choosing one value and applying it consistently:
backend/services/sinks.py#L427-L434 in place_call, backend/models.py#L870-L884
in the CallResponse docstring, backend/routes/sinks.py#L105-L113 in
call_endpoint, and docs/content/docs/overview/sinks.mdx#L111-L120 in the sample
response. Either return and document "dialing" or preserve the persisted
"initiating" value and update every documentation reference accordingly.
- Around line 206-215: Update the call creation flow around
TelnyxClient.create_call to use only data.get("call_control_id") and raise
ValueError when it is missing; do not fall back to call_session_id. Catch Telnyx
create_call HTTPStatusError failures and convert them into ValueError with the
relevant Telnyx response detail so backend/routes/sinks.py continues mapping
rejected requests to HTTP 400.
- Around line 264-268: Update the webhook token validation around
row.webhook_secret to fail closed when the stored secret is missing, returning
the existing bad_token response. For non-missing secrets, compare
row.webhook_secret and token with secrets.compare_digest, importing secrets as
needed; remove the legacy-row acceptance logic.
- Around line 283-296: Update the webhook handler around _wait_for_generation to
claim the row before waiting: add the answered/in-progress state to the
duplicate guard, set the intermediate status, and commit it before starting
asynchronous work. Move generation waiting and playback_start into a background
task, then return the webhook acknowledgment immediately so retries
short-circuit and cannot start playback twice.
- Around line 138-155: Add the Telnyx connection_id setting and
migration/default handling, include it in is_configured validation, and update
create_call to require and send connection_id in the POST /calls payload. Update
dial_and_play to read the configured connection_id and pass it to create_call,
preserving the existing outbound call flow.

In `@docs/content/docs/overview/sinks.mdx`:
- Around line 65-75: Update the Telnyx sink documentation around “Test
connection” and the “Call” button to clearly mark both UI features as planned or
not yet available, rather than describing them as currently usable. Preserve the
existing sink setup and call behavior descriptions while relocating these
controls to an appropriate roadmap note if needed.
- Around line 57-63: Update the field label in the configuration table from
default_profile to default_profile_id, matching the API field name used by
TelnyxSettingsResponse and TelnyxSettingsUpdate; leave the description
unchanged.
- Around line 158-160: Update the Telnyx webhook documentation link in the sink
overview text to the current official Telnyx webhook documentation URL, keeping
the surrounding signature-verification guidance unchanged.

---

Nitpick comments:
In `@backend/mcp_server/tools.py`:
- Around line 322-336: Remove the redundant ValueError try/except wrapper around
the sinks_service.place_call invocation in the tool handler, allowing the
original exception to propagate unchanged while preserving the existing call
arguments and result handling.
- Around line 289-319: The profile and personality resolution flow queries
MCPClientBinding twice for the same client_id. Fetch the binding once before the
profile fallback logic, reuse it for both profile and personality resolution,
and simplify the personality condition to avoid the nested if flagged by Ruff
SIM102.

In `@backend/routes/sinks.py`:
- Around line 168-169: Update the ValueError handler in the surrounding route
function to chain the original exception when raising HTTPException, preserving
exc as the explicit cause while keeping the existing status code and detail
unchanged.
- Around line 42-50: Extract the duplicated TelnyxSettingsResponse construction
into a shared _settings_response(row) helper, preserving all existing field
mappings and conversions. Update both get_telnyx_settings and
update_telnyx_settings to return _settings_response(row) so future settings
fields require one change.

In `@backend/services/sinks.py`:
- Line 224: Update both timestamp constructions in backend/services/sinks.py at
lines 224 and 336 to use datetime.UTC instead of timezone.utc, and remove the
now-unused timezone import.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bdf236e9-4554-4c8d-986e-c28f041d460d

📥 Commits

Reviewing files that changed from the base of the PR and between 51f49de and 553c614.

📒 Files selected for processing (10)
  • backend/database/__init__.py
  • backend/database/migrations.py
  • backend/database/models.py
  • backend/mcp_server/tools.py
  • backend/models.py
  • backend/routes/__init__.py
  • backend/routes/sinks.py
  • backend/services/sinks.py
  • docs/content/docs/overview/meta.json
  • docs/content/docs/overview/sinks.mdx

Comment on lines +238 to +247
async def voicebox_call(
to: str,
text: str | None = None,
generation_id: str | None = None,
profile: str | None = None,
engine: str | None = None,
personality: bool | None = None,
language: str | None = None,
auto_hangup: bool | None = None,
) -> dict[str, Any]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate to as E.164 in the MCP tool.

CallRequest.to enforces ^\+[1-9]\d{6,14}$ on the REST surface. This tool accepts any string. An agent that passes "555-123-4567" reaches Telnyx, and create_call raises httpx.HTTPStatusError, which the tool does not convert to a ValueError. The agent then receives an opaque error instead of a correction hint.

Validate the format before calling place_call.

🛡️ Proposed fix
+import re
...
             if bool(text) == bool(generation_id):
                 raise ValueError(
                     "Pass exactly one of `text` (generate inline) or "
                     "`generation_id` (use an existing generation)."
                 )
 
+            if not re.fullmatch(r"\+[1-9]\d{6,14}", to):
+                raise ValueError(
+                    "`to` must be an E.164 phone number, e.g. '+15551234567'."
+                )
+
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools.py` around lines 238 - 247, Update voicebox_call to
validate to against the same E.164 pattern enforced by CallRequest before
invoking place_call. Reject invalid values with a clear ValueError correction
message, and leave the existing call flow unchanged for valid numbers.

Comment thread backend/models.py
Comment on lines +917 to +921
public_base_url: Optional[str] = Field(
None,
max_length=200,
description="Public HTTPS URL of this Voicebox server (ngrok, Tailscale Funnel, etc.).",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate the public_base_url scheme.

The description states the value is a public HTTPS URL. The field accepts any string up to 200 characters. Telnyx does not POST to http:// URLs, which docs/content/docs/overview/sinks.mdx also states. If a user saves an http:// URL or a non-URL string, the save succeeds and the failure appears later as an opaque Telnyx error during the call.

Reject a non-HTTPS value at settings-save time.

🛡️ Proposed fix to validate the URL scheme
     public_base_url: Optional[str] = Field(
         None,
         max_length=200,
+        pattern=r"^https://\S+$",
         description="Public HTTPS URL of this Voicebox server (ngrok, Tailscale Funnel, etc.).",
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public_base_url: Optional[str] = Field(
None,
max_length=200,
description="Public HTTPS URL of this Voicebox server (ngrok, Tailscale Funnel, etc.).",
)
public_base_url: Optional[str] = Field(
None,
max_length=200,
pattern=r"^https://\S+$",
description="Public HTTPS URL of this Voicebox server (ngrok, Tailscale Funnel, etc.).",
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/models.py` around lines 917 - 921, Update the public_base_url field
validation in the model containing public_base_url to accept only valid HTTPS
URLs, while allowing None for unset configuration. Reject HTTP URLs and non-URL
strings during settings validation, preserving the existing 200-character limit
and description.

Comment thread backend/routes/sinks.py Outdated
Comment thread backend/services/sinks.py Outdated
Comment thread backend/services/sinks.py
Comment thread backend/services/sinks.py Outdated
Comment thread backend/services/sinks.py
Comment on lines +427 to +434
return {
"call_control_id": call_row.call_control_id,
"status": call_row.status,
"generation_id": used_generation_id,
"profile": profile_name,
"to": to,
"poll_url": f"/generate/{used_generation_id}/status",
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

The returned call status is "initiating", not the documented "dialing". dial_and_play persists status="initiating" and place_call returns that value verbatim, but three documentation sites promise "dialing". Pick one value and align every site.

  • backend/services/sinks.py#L427-L434: return the documented "dialing" value, or change the persisted status at line 223 to match the contract.
  • backend/models.py#L870-L884: update the CallResponse docstring to the value actually returned.
  • backend/routes/sinks.py#L105-L113: update the call_endpoint docstring claim status="dialing".
  • docs/content/docs/overview/sinks.mdx#L111-L120: update the sample response body "status": "dialing".
📍 Affects 4 files
  • backend/services/sinks.py#L427-L434 (this comment)
  • backend/models.py#L870-L884
  • backend/routes/sinks.py#L105-L113
  • docs/content/docs/overview/sinks.mdx#L111-L120
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/sinks.py` around lines 427 - 434, Align the call status
contract across all affected sites by choosing one value and applying it
consistently: backend/services/sinks.py#L427-L434 in place_call,
backend/models.py#L870-L884 in the CallResponse docstring,
backend/routes/sinks.py#L105-L113 in call_endpoint, and
docs/content/docs/overview/sinks.mdx#L111-L120 in the sample response. Either
return and document "dialing" or preserve the persisted "initiating" value and
update every documentation reference accordingly.

Comment on lines +57 to +63
| Field | Value |
| --- | --- |
| `api_key` | Your Telnyx API key |
| `from_number` | The number you bought, in E.164 (`+1...`) |
| `public_base_url` | The tunnel URL from step 2 |
| `default_profile` | The voice profile to use when a caller doesn't specify one |
| `auto_hangup` | Whether to hang up the call after playback ends (default: on) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the real field name default_profile_id.

The other rows in this table use the exact API field names (api_key, from_number, public_base_url, auto_hangup). The profile row says default_profile. The field is default_profile_id in TelnyxSettingsResponse and TelnyxSettingsUpdate.

📝 Proposed fix
-| `default_profile` | The voice profile to use when a caller doesn't specify one |
+| `default_profile_id` | The voice profile to use when a caller doesn't specify one |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| Field | Value |
| --- | --- |
| `api_key` | Your Telnyx API key |
| `from_number` | The number you bought, in E.164 (`+1...`) |
| `public_base_url` | The tunnel URL from step 2 |
| `default_profile` | The voice profile to use when a caller doesn't specify one |
| `auto_hangup` | Whether to hang up the call after playback ends (default: on) |
| Field | Value |
| --- | --- |
| `api_key` | Your Telnyx API key |
| `from_number` | The number you bought, in E.164 (`+1...`) |
| `public_base_url` | The tunnel URL from step 2 |
| `default_profile_id` | The voice profile to use when a caller doesn't specify one |
| `auto_hangup` | Whether to hang up the call after playback ends (default: on) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/content/docs/overview/sinks.mdx` around lines 57 - 63, Update the field
label in the configuration table from default_profile to default_profile_id,
matching the API field name used by TelnyxSettingsResponse and
TelnyxSettingsUpdate; leave the description unchanged.

Comment thread docs/content/docs/overview/sinks.mdx Outdated
Comment on lines +65 to +75
Hit **Test connection** — Voicebox will ping Telnyx and tell you if the key
is valid.

## Using the sink

### From the UI

When the Telnyx sink is configured, a **Call** button appears on each
generation card next to Play and Speak. Click it, enter a destination
number, and dial. The generation plays on the call once the callee picks
up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Mark the UI features as not yet available.

The PR objectives state that this change includes no frontend UI changes. This page documents a Test connection button and a Call button on each generation card as available today. A reader will look for controls that do not exist yet.

Label both as planned, or move them to a roadmap note.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/content/docs/overview/sinks.mdx` around lines 65 - 75, Update the Telnyx
sink documentation around “Test connection” and the “Call” button to clearly
mark both UI features as planned or not yet available, rather than describing
them as currently usable. Preserve the existing sink setup and call behavior
descriptions while relocating these controls to an appropriate roadmap note if
needed.

Comment thread docs/content/docs/overview/sinks.mdx Outdated
Comment on lines +158 to +160
For full webhook signature verification against Telnyx's HMAC header, see
the [Telnyx webhook docs](https://developers.telnyx.com/docs/api/v2/webhooks).
This is a follow-up hardening step tracked in the sink's TODO.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check that the Telnyx webhook docs URL resolves.
curl -sSL -o /dev/null -w '%{http_code} %{url_effective}\n' \
  'https://developers.telnyx.com/docs/api/v2/webhooks'

Repository: jamiepine/voicebox

Length of output: 259


Update the Telnyx webhook documentation link

The URL redirects to an unrelated OpenAI chat endpoint. Replace it with the current Telnyx webhook documentation URL.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/content/docs/overview/sinks.mdx` around lines 158 - 160, Update the
Telnyx webhook documentation link in the sink overview text to the current
official Telnyx webhook documentation URL, keeping the surrounding
signature-verification guidance unchanged.

…etry-safe

The sink could not place a call. Telnyx's Dial API requires connection_id
alongside to/from, and playback_start takes audio_url (there is no media_url
field on that action), so every dial and every playback returned 422. Add a
connection_id setting with a migration, correct the playback field, and pass
Telnyx's error detail through as a 400 instead of letting raise_for_status
surface an opaque 500.

Webhooks are delivered at least once, and call.answered could block up to 30s
waiting for a generation while holding the request open, so Telnyx would retry
and a second playback_start would double the audio. Ack immediately, claim the
row before doing slow work, and run the wait in a background task with its own
session.

Also: resolve profiles through the same single resolve_profile call that
/speak uses so per-client bindings and the global default apply to /call too,
honor the binding's default engine and personality, compare the webhook token
in constant time and fail closed when it is absent, drop the call_session_id
fallback that would silently address an uncontrollable call, and thread
auto_hangup in at row creation rather than patching it after the dial.

Docs gain the two undocumented required fields (connection_id, enabled), a
warning that tunneling port 17493 publishes every unauthenticated Voicebox
route, and a correction that the Call button is not shipped yet.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/mcp_server/tools.py (1)

253-256: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The docstring omits the global capture default from the precedence chain.

The docstring states "explicit arg → per-client MCP binding → sink default". resolve_profile in backend/mcp_server/resolve.py also consults CaptureSettings.default_playback_voice_id before returning None. The real order is explicit arg → per-client binding → global capture default → sink default, so s.default_profile_id is only used when the global default is unset or missing. Update the docstring, or resolve the sink default before the global default if the sink default is meant to win.

Also applies to: 284-294

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools.py` around lines 253 - 256, Update the profile
resolution documentation around the tool’s profile argument to include the
global capture default between the per-client MCP binding and sink default.
Ensure the documented precedence matches resolve_profile: explicit argument,
per-client binding, CaptureSettings.default_playback_voice_id, then
s.default_profile_id.
♻️ Duplicate comments (1)
backend/services/sinks.py (1)

289-302: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Map Telnyx transport failures to ValueError too.

_raise_for_telnyx_status covers HTTP 4xx/5xx responses, so a rejected number or bad connection_id now reaches the route as a client error. A transport failure does not. If Telnyx is unreachable or the 10 s timeout expires, httpx.ConnectError or httpx.ReadTimeout propagates out of dial_and_play, and backend/routes/sinks.py maps only ValueError to a 400, so the caller gets an opaque 500.

🐛 Proposed fix
-    async with TelnyxClient(s.api_key) as client:
-        data = await client.create_call(
-            to=to,
-            from_=s.from_number,
-            connection_id=s.connection_id,
-            webhook_url=webhook_url,
-        )
+    try:
+        async with TelnyxClient(s.api_key) as client:
+            data = await client.create_call(
+                to=to,
+                from_=s.from_number,
+                connection_id=s.connection_id,
+                webhook_url=webhook_url,
+            )
+    except httpx.HTTPError as exc:
+        raise ValueError(f"Could not reach Telnyx: {exc}") from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/sinks.py` around lines 289 - 302, Update dial_and_play to
catch Telnyx transport failures, including httpx.ConnectError and
httpx.ReadTimeout raised by TelnyxClient.create_call, and re-raise them as
ValueError with useful context so backend/routes/sinks.py preserves the
client-error response path. Leave the existing call_control_id validation and
HTTP-status handling unchanged.
🧹 Nitpick comments (5)
backend/tests/test_telnyx_sink.py (1)

42-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reaching into client._client couples the test to a private attribute.

_client_with builds a TelnyxClient, discards its httpx.AsyncClient, and replaces it. The discarded client is never closed, and any rename of _client breaks all six tests at once. Consider an optional injection point on TelnyxClient, for example a transport keyword forwarded to httpx.AsyncClient, so the tests use the public constructor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/test_telnyx_sink.py` around lines 42 - 56, Add an optional
transport injection parameter to the TelnyxClient constructor and forward it to
httpx.AsyncClient. Update _client_with to construct TelnyxClient with the
MockTransport directly, removing the private _client replacement and preventing
the unused client from being leaked.
backend/services/sinks.py (2)

374-384: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Log exceptions raised inside detached tasks.

_run_call_answered guards playback_start, but the settings read, the generation poll, and every db.commit() run unguarded. If one of them raises, the task dies, the row stays in preparing, and the only trace is an asyncio "Task exception was never retrieved" message emitted at collection time. Add a done callback that logs the exception so a stuck call is diagnosable.

♻️ Proposed refactor
 def _spawn(coro) -> None:
     task = asyncio.create_task(coro)
     _BACKGROUND_TASKS.add(task)
-    task.add_done_callback(_BACKGROUND_TASKS.discard)
+    task.add_done_callback(_on_task_done)
+
+
+def _on_task_done(task: asyncio.Task) -> None:
+    _BACKGROUND_TASKS.discard(task)
+    if task.cancelled():
+        return
+    exc = task.exception()
+    if exc is not None:
+        logger.error("Telnyx webhook background task failed", exc_info=exc)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/sinks.py` around lines 374 - 384, Update _spawn to add a
done callback that retrieves and logs any exception from the detached task,
while preserving _BACKGROUND_TASKS cleanup. Ensure normal completion and
cancellation do not produce error logs, and use the module’s existing logging
mechanism.

479-479: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Apply Ruff UP017 to both UTC call sites. Import UTC explicitly and replace both timezone.utc uses with UTC. The project requires Python 3.12 and targets py312.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/services/sinks.py` at line 479, Update both UTC call sites, including
the assignment in the relevant completion flow, to import UTC explicitly and
replace timezone.utc with UTC. Remove the no-longer-needed timezone import if
applicable, preserving the existing timezone-aware datetime behavior.

Source: Linters/SAST tools

backend/mcp_server/tools.py (1)

296-311: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared binding-defaults lookup.

Lines 296-311 repeat lines 85-99 of voicebox_speak exactly: load MCPClientBinding, then derive resolved_personality and resolved_engine. A future default (for example default_language) must then be added in two places. Extract one helper, for example _resolve_binding_defaults(client_id, personality, engine, db), and call it from both tools.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools.py` around lines 296 - 311, The duplicated binding
lookup and default resolution in voicebox_speak and the shown tool should be
centralized. Add a shared _resolve_binding_defaults helper that accepts
client_id, personality, engine, and db, performs the MCPClientBinding lookup,
and returns the resolved defaults; replace both tools’ duplicated blocks with
calls to this helper while preserving their existing resolved_personality,
use_persona, and resolved_engine behavior.
requirements.txt (1)

10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Check pinning consistency for httpx.

httpx is now a declared runtime dependency, which is correct because backend/services/sinks.py and backend/services/cloud.py import it directly. The entry carries no version constraint. If the rest of this file pins versions, pin httpx too so builds stay reproducible.

#!/bin/bash
# Description: Show the requirements manifest to compare pinning style.
cat -n requirements.txt
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@requirements.txt` at line 10, Update the httpx dependency entry in
requirements.txt to follow the file’s established version-pinning convention,
using an explicit compatible version constraint consistent with the other
runtime dependencies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@backend/mcp_server/tools.py`:
- Around line 253-256: Update the profile resolution documentation around the
tool’s profile argument to include the global capture default between the
per-client MCP binding and sink default. Ensure the documented precedence
matches resolve_profile: explicit argument, per-client binding,
CaptureSettings.default_playback_voice_id, then s.default_profile_id.

---

Duplicate comments:
In `@backend/services/sinks.py`:
- Around line 289-302: Update dial_and_play to catch Telnyx transport failures,
including httpx.ConnectError and httpx.ReadTimeout raised by
TelnyxClient.create_call, and re-raise them as ValueError with useful context so
backend/routes/sinks.py preserves the client-error response path. Leave the
existing call_control_id validation and HTTP-status handling unchanged.

---

Nitpick comments:
In `@backend/mcp_server/tools.py`:
- Around line 296-311: The duplicated binding lookup and default resolution in
voicebox_speak and the shown tool should be centralized. Add a shared
_resolve_binding_defaults helper that accepts client_id, personality, engine,
and db, performs the MCPClientBinding lookup, and returns the resolved defaults;
replace both tools’ duplicated blocks with calls to this helper while preserving
their existing resolved_personality, use_persona, and resolved_engine behavior.

In `@backend/services/sinks.py`:
- Around line 374-384: Update _spawn to add a done callback that retrieves and
logs any exception from the detached task, while preserving _BACKGROUND_TASKS
cleanup. Ensure normal completion and cancellation do not produce error logs,
and use the module’s existing logging mechanism.
- Line 479: Update both UTC call sites, including the assignment in the relevant
completion flow, to import UTC explicitly and replace timezone.utc with UTC.
Remove the no-longer-needed timezone import if applicable, preserving the
existing timezone-aware datetime behavior.

In `@backend/tests/test_telnyx_sink.py`:
- Around line 42-56: Add an optional transport injection parameter to the
TelnyxClient constructor and forward it to httpx.AsyncClient. Update
_client_with to construct TelnyxClient with the MockTransport directly, removing
the private _client replacement and preventing the unused client from being
leaked.

In `@requirements.txt`:
- Line 10: Update the httpx dependency entry in requirements.txt to follow the
file’s established version-pinning convention, using an explicit compatible
version constraint consistent with the other runtime dependencies.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 576306a3-d5ed-4091-a451-47b96e8ad27d

📥 Commits

Reviewing files that changed from the base of the PR and between 553c614 and a6c8df0.

📒 Files selected for processing (9)
  • backend/database/migrations.py
  • backend/database/models.py
  • backend/mcp_server/tools.py
  • backend/models.py
  • backend/routes/sinks.py
  • backend/services/sinks.py
  • backend/tests/test_telnyx_sink.py
  • docs/content/docs/overview/sinks.mdx
  • requirements.txt
🚧 Files skipped from review as they are similar to previous changes (3)
  • backend/models.py
  • backend/routes/sinks.py
  • backend/database/models.py

@a692570

a692570 commented Aug 13, 2026

Copy link
Copy Markdown
Author

Pushed two commits (a6c8df0, b0021ed) and rewrote the description. Self-review turned up two bugs that meant the sink couldn't have worked against the live API, so flagging them plainly rather than burying them in a diff.

Two wire-contract bugs

  1. POST /v2/calls was missing connection_id. The Dial API's required set is [connection_id, to, from], so every dial would have been rejected. There was no connection_id column, setting, or docs field anywhere in the branch. Added, with a migration that backfills the column for anyone who ran the earlier build.
  2. playback_start sent media_url. That field doesn't exist on the action; it's audio_url (media_name is the separate uploaded-media path). Even on a connected call, playback would have been rejected.

My earlier "tested end to end, playback clean" note in the description was wrong and I've corrected it. Both paths are now covered by tests that assert the exact JSON sent to Telnyx, and I verified those tests fail if either fix is reverted. Live PSTN re-verification is still pending on my side.

Webhook retry safety

call.answered waited up to 30s for the generation while holding the webhook request open. Telnyx wants a 2xx within 2000 ms and retries otherwise, and it queues consecutive play commands, so a slow generation meant a timeout, a retry, and the same clip playing more than once on the call. The old idempotency guard only closed after the wait, so retries sailed past it.

The handler now acks immediately, claims the call row before doing anything slow, and runs the wait in a background task with its own session. Retried deliveries see the claim and exit.

Smaller fixes

  • Telnyx error bodies are unpacked into a 400 instead of raise_for_status producing an opaque 500. Misconfiguration is the normal failure here, so the message should say which field Telnyx rejected.
  • /call now resolves profiles through the same single resolve_profile call /speak uses. It previously skipped per-client MCP bindings and the global capture default, and ignored the binding's default_engine and default_personality, despite claiming parity with voicebox.speak.
  • The enabled flag defaulted to false and wasn't documented, so following the setup guide exactly still produced "not configured". The request gate and dial_and_play also disagreed about whether to check it. Both now share one missing_settings() helper, and GET /sinks/telnyx returns a missing array so the UI can show what's outstanding.
  • Webhook token now compares with secrets.compare_digest and fails closed when no secret is stored, instead of != and fail-open.
  • Dropped a call_control_id or call_session_id fallback that would have silently stored an ID no call-control action accepts.
  • httpx was already imported at module scope by services/cloud.py but never declared; it's in requirements.txt now.

Answers to my own open questions

1. Webhook signature verification: I'd take it as a follow-up, but I'll do it here if you'd rather. The per-call token scopes each webhook to one call and fails closed, which is a real gate, but it rides in the query string and so lands in the access logs of whatever proxy fronts the tunnel. Telnyx's Ed25519 signature (telnyx-signature-ed25519 + telnyx-timestamp over {timestamp}|{raw body}, checked against the account public key, rejecting timestamps older than five minutes) authenticates the sender rather than just scoping the URL. The cost is a crypto dependency and another settings field, which is why I left it out of a PR that's already large. Your call on the dependency question, since that's a project-level decision.

2. Sink vs. pipeline routing: I'd merge additive now. The Telnyx-specific surface is the HTTP client and the two webhook events, and that logic survives a reframing into a sink node unchanged. The settings table is isolated and the routes are namespaced under /sinks/telnyx, so the refactor should be a move rather than a rewrite. If you'd rather not carry a second settings-table pattern before the interface exists, I'm happy to hold. The thing I'd want from you either way is a steer on naming so this doesn't become the odd one out later.

3. Frontend "Call" button: follow-up, and the docs now say so. The original docs described a button that didn't exist, which was my error; that section is now marked as not yet shipped. Backend surface is complete and usable via MCP and curl today. Happy to add the UI here if you'd prefer to review it as one unit.

One thing worth your opinion

Telnyx has to reach this server, but Voicebox binds to loopback and has no request auth, so a plain ngrok http 17493 publishes every route: profiles, generations, settings, deletes. The docs now warn about this and tell users to front the tunnel with a proxy allowing only POST /sinks/telnyx/webhook and GET /audio/.

That works, but it puts the burden on the user and it's easy to skip. If sinks become a recurring pattern, Voicebox might want a first-class "expose only these paths" mode. That's beyond this PR, and I didn't want to invent that surface unilaterally, but the need shows up the moment any sink needs an inbound callback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant