Skip to main content

Voice Tool Dispatcher

Overview

POST /api/incoming-call-webhook-tool/[name] is the dynamic catch-all route ElevenLabs calls for every voice tool. The tool name is the URL slug — e.g. /api/incoming-call-webhook-tool/skills. The route dispatches through the shared executeTool() registry in app/api/claude/tools/executors/index.ts, which means voice picks up the same tools, truncation rules, and logging as chat / SMS / email. This coexists with the 42 legacy static routes (.../get-financial-metrics/, .../get-crypto-holdings/, etc.). Next.js resolves static routes before dynamic ones, so the existing per-tool files keep working unchanged — the [name] route only handles slugs with no static file (e.g. skills, execute_sql). Once Phase 4 deletes the 42 static directories, [name] absorbs every call and ElevenLabs keeps hitting the same base URL the whole time. No URL migration, no rollback risk.

Request contract

ElevenLabs sends JSON to POST /api/incoming-call-webhook-tool/<tool_name>:

Response

Success (200):
Failure:
  • 400 — malformed JSON body, missing tool name in URL, or missing/invalid user_id in body
  • 401 — bad or missing x-tool-secret
  • 403 — tool is not opted into the voice surface
  • 500 — ELEVENLABS_TOOL_SECRET not configured, or the dispatcher itself threw before reaching the executor
Executor-level failures (database errors, bad SQL, Finnhub 4xx, etc.) come back in-band as a 200 with the executor’s own error JSON in the result field — matching the shape chat/SMS/email see. The 500 path is reserved for the dispatcher itself misbehaving.

Surface eligibility

A tool is only callable over voice if it’s listed in the local VOICE_ALLOWED_TOOLS set at the top of the dispatcher route. Tools not opted in return 403 even if the executor exists — this keeps chat-only tools (writes, PDF generators) from leaking to telephony. Current voice-enabled tools. Each must exist in the shared executor registry (toolRegistry in app/api/claude/tools/executors/index.ts); the dispatcher allowlist is a strict subset of it, so a tool that was removed or renamed upstream must also be dropped here (otherwise it 403-passes and comes back as an in-band “Unknown tool”):
  • get_current_time
  • skills (POSIX shell over the skills filesystem + persistent personal-memory writes — previously unreachable from voice; voice has the same op="search" | "write" | "delete" access chat does, since memory is shared across surfaces)
  • send_email (parity with chat/SMS/email; voice already supports email via the legacy app/api/incoming-call-webhook-send-email-tool endpoint that the ElevenLabs system prompt drives — keeping it on the dispatcher avoids a regression once Phase 4 deletes the legacy routes)
  • execute_sql (read financial data — radar queries and the financial-statement schedule are now expressed as SQL rather than dedicated tools)
  • execute_python (Lambda microVM sandbox — Finnhub SDK, pandas, numpy, requests, S3 session save/load; also serves web search and historical/deep research, which no longer have standalone tools)
Specialized read tools voice used to call directly (search_web, get_radar_events / get_radar_event_by_id / get_radar_results, get_current_financial_statement_schedule, fetch_historical_deep_research) were consolidated into execute_sql / execute_python upstream and are no longer separate registry entries — so they’re intentionally absent here. Adding a new tool to voice:
  1. Append the snake_case name to VOICE_ALLOWED_TOOLS in the dispatcher.
  2. Register a Server Tool with the same name in the ElevenLabs dashboard.
A follow-up PR will consolidate this local allowlist with SMS/email’s inline exclusion arrays into a single per-surface map.

ElevenLabs agent configuration

In the ElevenLabs dashboard, create one Server Tool per voice-enabled tool. Each tool gets its own URL slug; the body just carries the tool’s own params plus user_id. Example — the skills server tool:
  • Method: POST
  • URL: https://<app-host>/api/incoming-call-webhook-tool/skills
  • Headers:
    • Content-Type: application/json
    • x-tool-secret: {{ELEVENLABS_TOOL_SECRET}} (from agent secret)
  • Body template (JSON):
    Same input shape as the chat tool: op is search | write | delete, command is required for search, filename for write/delete, content for write. The dispatcher only checks the tool name — per-op argument validation happens inside the executor.
  • Description / parameter schema: copy verbatim from the matching entry in regularTools in app/api/claude/tools/definitions.ts so the voice LLM sees the same tool docs the chat LLM gets.
Repeat once per voice-enabled tool. Each has its own URL, natural body shape, and same auth header. The full, authoritative agent system prompt lives in ./voice-system-prompt.md. It currently targets the legacy 42-route architecture — a follow-up PR will update it to teach the voice model about the new dispatcher tools alongside (or in place of) the legacy ones.

Debugging

Every dispatch is logged:
  • [Voice Tool] Dispatching <tool_name> — accepted request (no clerkId in the dispatcher’s own log line to keep raw user identifiers out of every request log; per-user tracing flows through the shared tool-logger instead, same as chat/SMS/email)
  • [Tool Execute] <tool_name>: — underlying executor logging (same as chat)
  • [Voice Tool] Unauthorized dispatch attempt — 401
  • [Voice Tool] Tool "<name>" is not enabled for voice surface — 403
  • [Voice Tool] Dispatcher error: <error> — server-side-only log of the full exception; the client always receives a generic 500 body
Tool outputs flow into the shared logToolSuccess / logToolError helpers, so voice traffic is visible in the same place as chat/SMS/email. (logs/tool-calls.log is dev-only — file writes are a no-op in production.)

Migration plan

The 42 legacy static routes under app/api/incoming-call-webhook-tool/* predate the shared executor registry. They each do a hand-rolled auth check and call one specific data-cache function — no skills access, no shared logging, no truncation.
  1. Phase 2 (shipped) — dynamic [name] route added under the same directory. Static routes keep serving the 42 existing URLs; the dynamic route handles any new slug that has no static file.
  2. Phase 3 — configure new ElevenLabs server tools at the new slugs (/skills, /execute_sql, etc.). Update the agent system prompt in the ElevenLabs dashboard to match the latest ./voice-system-prompt.md once it’s been updated to reference the new dispatcher tools. Staging canary first. No change to the 42 legacy tools during this phase — they keep working, giving us a risk-free rollback path.
  3. Phase 4 — once production has baked on the new tools, delete the 42 static route directories. The dynamic [name] route handles everything that remains. ElevenLabs URLs are unchanged from Phase 3.