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 toPOST /api/incoming-call-webhook-tool/<tool_name>:
Response
Success (200):400— malformed JSON body, missing tool name in URL, or missing/invaliduser_idin body401— bad or missingx-tool-secret403— tool is not opted into thevoicesurface500—ELEVENLABS_TOOL_SECRETnot configured, or the dispatcher itself threw before reaching the executor
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 localVOICE_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_timeskills(POSIX shell over the skills filesystem + persistent personal-memory writes — previously unreachable from voice; voice has the sameop="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 legacyapp/api/incoming-call-webhook-send-email-toolendpoint 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)
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:
- Append the snake_case name to
VOICE_ALLOWED_TOOLSin the dispatcher. - Register a Server Tool with the same name in the ElevenLabs dashboard.
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 plususer_id.
Example — the skills server tool:
- Method:
POST - URL:
https://<app-host>/api/incoming-call-webhook-tool/skills - Headers:
Content-Type: application/jsonx-tool-secret: {{ELEVENLABS_TOOL_SECRET}}(from agent secret)
- Body template (JSON):
Same input shape as the chat tool:
opissearch | write | delete,commandis required for search,filenamefor write/delete,contentfor 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
regularToolsinapp/api/claude/tools/definitions.tsso the voice LLM sees the same tool docs the chat LLM gets.
./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
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 underapp/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.
- 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. - 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.mdonce 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. - 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.