Appearance
Anatomy of an Avoca call — end to end
The narrative walkthrough of what happens when a phone rings at an Avoca-managed voice assistant. Covers all six stages, every branch, and explicitly calls out the things we're not doing today that we could be.
How to read this
Start here if you want to understand how the system works. The page is structured as a sequential narrative with the decision branches called out inline. Each stage has a "what's happening" section and a "where to look if it broke" pointer. A real call is used as the worked example throughout (vapi-call-id: 019e1499-fb08-7002-9717-ad177aa8e2db, EAS Tire & Auto - Ponderosa, 2026-05-11 01:14 UTC).
The six stages
The four phases: pre-conversation setup (Stages 0-1), the conversation itself (Stages 2-3), end + side effects (Stages 4-5), persistence (Stage 6).
Stage 0: Pre-call (transport routing)
What happens
- Caller dials the test phone (or production phone) number — a Twilio number.
- Twilio receives the inbound call.
- Twilio looks at the phone number's Voice URL and POSTs there. For Avoca, voice URL points at Avoca's routing webhook:
https://app.avoca.ai/api/twilio/webhooks/inbound-call. - Avoca's routing webhook reads
assistant_configs.platformfor this phone's voice assistant and decides:platform = 'vapi'→ respond with TwiML that hands the call to Vapiplatform = 'elevenlabs'→ respond with TwiML that hands the call to ElevenLabs Convai
For EAS Ponderosa, platform is vapi. Twilio forwards the audio stream to Vapi from here on.
In your test call
This stage happened in ~50ms. Twilio CallSid was issued; Vapi received the call setup at time: 1778462096657. No log entries from your dev server because this stage doesn't hit Avoca's local code — the routing webhook is hosted at app.avoca.ai, not your tunnel.
Where to look if it broke
- Twilio dashboard → call logs → Voice URL hit status (200/500/timeout)
- Datadog →
service:avoca-next-prodfiltered to/api/twilio/webhooks/inbound-callfor this call assistant_configs.platform— wrong column value means wrong handoff (e.g., the test phone's assistant isvapi, but if it was changed toelevenlabsthe routing would diverge)
Branch: what determines the platform?
assistant_configs.platform is set per-assistant. For voice-agent clients, this is almost always vapi today. ElevenLabs Convai is a parallel platform used for some experiments. For EAS Ponderosa: vapi.
Branch: which config does the routing webhook pick? (ElevenLabs path)
Verified observations
apps/web/pages/api/twilio/webhooks/inbound-call.ts resolves an effectiveConfig for the call before deciding platform, in this precedence order (first match wins):
- Live A/B experiment variant — non-test calls only, keyed on the voice assistant + the Twilio CallSid via Statsig (
getExperimentAssignment). Test calls are excluded so a live experiment can't hijack a Hamming test run. - Ephemeral sim-test run — test calls only, while a Hamming run started against a specific config is in flight (
team_test_plans.assistant_test_config_id, viagetActiveSimTestConfigIdForPhone). - Standing test-config override — test calls only, an admin-set
phone_numbers.assistant_config_idon the test number's own row (toggled via aFlaskConicalicon next to the assistant-config list's "Default" star). Falls through when no run is active. - Default —
voice_assistants.default_assistant_config_id, same config every other number uses.
For the ElevenLabs platform specifically, this same precedence is re-applied inside handleElevenLabs (effectiveAssistantConfigId) to pick which config actually builds the agent that answers the call — the outer effectiveConfig above only drives the Vapi-vs-ElevenLabs platform decision and ElevenLabs tool-parity check, so both computations have to agree.
VAPI test calls don't use this per-call resolution at all. VAPI routing is baked into a squad synced onto the test phone ahead of time by syncTestPhoneForVoiceAssistant — this is true even for a call that ends up on the ElevenLabs platform, since VAPI is always kept as the Twilio voice-fallback path. That sync function tracks, in the same priority order as above: an explicit ephemeral override (a Hamming run in flight) → the test phone's standing phone_numbers.assistant_config_id override → the VA default. Every trigger that can re-sync the test phone (the nightly cron, the on-demand "resync test phone" admin action, and the config-editor's auto-resync after a default-config save) goes through this one function, so the standing override survives all of them rather than only the one write path that sets it.
Not the same thing as Simulation Testing v2's "Select Agent Config." That feature (dashboard StartTestRunSheet.tsx → generateTeamTestPlan({ assistantConfigId }) → team_test_plans.assistant_test_config_id) is item 2 above — an automated test run exercising a specific config, ephemeral, reverting to default when the run completes. The standing override (item 3) is for manual, iterative testing — tweak a blueprint/config, then call the test number yourself to hear it — and persists until an admin explicitly changes it.
How to use it, in practice: open a voice assistant's config list, find the config you want to try, and click the test-tube icon next to the star (mirrors the Star/"Default" toggle exactly — same button shape, its own spinner). That config gets a purple "Test" badge, the test phone re-syncs immediately, and calling the team's test number now answers with it — the production number is untouched. Toggle it off (or toggle a different config's test-tube on) to switch back. Verified live against team 2482 across both a VAPI config and an ElevenLabs config on 2026-07-30 — the standing override correctly drove routing on both platforms while the production number stayed on its own config throughout.
If you see a toast naming the VAPI test squad specifically (rather than the usual "Test config updated successfully"), the database write still succeeded — ElevenLabs test calls already use the new config — but the VAPI squad sync half failed, so VAPI test calls may still answer with the old config until the sync succeeds (retry the toggle, or use the admin "resync test phone" action).
See .indusk/planning/archive/test-number-config-override/adr.md in the FDE workbench for the full design reasoning.
Stage 1: assistant-request (dynamic assistant resolution)
What happens
Vapi now needs to know which assistant should handle this call. Instead of hardcoding the assistant on the phone number, Avoca uses dynamic resolution: Vapi POSTs messageType: assistant-request to Avoca's responder webhook, and Avoca returns the assistant config in the response body.
POST /api/responder/common/workflow?teamId=2678
{ message: { type: "assistant-request", customer: { number: "+1..." }, ... } }Avoca's responder handler:
- Reads the assistant_configs row for this voice assistant
- Builds the assistant config (system prompt, tools, voice settings, etc.) via
AgentFactory.createAgentFromVoiceAssistantId(...) - Optional branch (currently NOT enabled for EAS): pre-call customer enrichment — see callout below
- Returns the assistant or squad payload to Vapi
Vapi receives the config, starts the call.
⚠️ What we are NOT doing — pre-call customer enrichment
This is the lever for personalizing the agent's first message based on caller-ID lookup. Today, EAS does not do this. The agent has no pre-loaded customer info when the call starts; it has to call autoOpsLookupCustomer mid-conversation.
How it would work if enabled:
ts
// In Avoca's assistant-request handler
const callerPhone = req.message.customer?.number;
const customers = await autoOps.lookupCustomer({ phone: callerPhone });
if (customers.length === 1) {
return {
assistant: assistantConfig,
assistantOverrides: {
variableValues: {
customer_first_name: customers[0].firstName,
customer_is_known: 'true',
},
},
};
}The system prompt would be templated with {{customer_first_name}} and {{customer_is_known}} so the LLM sees the resolved version. Agent could open with "Hi Sandy, how can I help today?" instead of "Thanks for calling, this is Sarah."
Tradeoffs:
- Pro: personalized greeting; saves an in-call tool roundtrip
- Con: adds 200-500ms latency to call-start (synchronous AutoOps lookup); fails awkwardly when caller-ID is wrong (e.g., someone calling from a family member's phone); needs careful fallback when AutoOps is slow/down
- Open: should be its own design proposal if Avoca wants it. Not in current scope.
Why we know it's not enabled for EAS: the system prompt in the LLM request logs has zero {{ }} template variables — it's static text. (See in-call sequence for the LLM context shape.)
In your test call
Your dev server received POST /api/responder/common/workflow?messageType=assistant-request at the start of the call. Avoca returned the EAS Inbound Ponderosa assistant config. No variableValues were set — the prompt sent to the LLM had no substitutions. You can verify by checking the first LLM request in the Vapi call log: messages[0].content is the static "You are Sarah..." block.
Where to look if it broke
- Avoca dev server log (when routing locally) — look for
POST /api/responder/common/workflowwithmessageType=assistant-request - Datadog (when routing prod) — same filter, in the cloud
- Vapi call object →
assistantfield shows what Vapi resolved as the assistant config.assistantOverrides.variableValuesshows what enrichment was returned (empty for EAS).
Branch: MULTI_AGENT vs single-agent assistants
The assistant_configs.assistant_mode column determines what Avoca returns:
assistant_mode = 'VAPI_ASSISTANT'/'BUILDER_ASSISTANT'→ returns{ assistantId: '...' }referencing a Vapi-resident assistantassistant_mode = 'MULTI_AGENT'→ returns{ squad: { ... } }— Avoca builds the squad payload in-process and returns it inline
EAS Inbound Ponderosa is officially MULTI_AGENT per assistant_configs.assistant_mode. However, the test phone's Vapi call shows assistantName: "Basic Booking" (a single-agent shape) — flag this discrepancy: either the test phone is wired to a different test assistant, or there's a mismatch worth investigating. See vapi-squads.md for the multi-agent context.
Stage 2: First message (no LLM)
What happens
Vapi speaks the assistant's static firstMessage value. This does not invoke the LLM — it's pre-recorded text passed through the TTS provider directly.
For EAS: "Thanks for calling EAS Tire & Auto, this is Sarah. How can I help you today?"
In your test call
time: 1778462096871— Vapi enqueues the first messagetime: 1778462097345— Bot starts speaking (TTS audio begins)time: 1778462100917— Bot stops speaking (~3.5 seconds for the greeting)
Where to look if it broke
- Vapi call log →
pipeline.firstMessageStartedandpipeline.firstMessageCompletedevents - Assistant config → the
firstMessagefield
Branch: what if firstMessage is missing?
If the assistant has no firstMessage, Vapi waits for the user to speak first. For EAS, the static greeting is set so the agent always speaks first.
Stage 3: The in-call loop
What happens
This is the heart of the call. Per turn:
User speaks
→ Deepgram transcribes (turn-final transcript)
→ Vapi assembles LLM request:
- system prompt (full, repeated every turn)
- bot/user/tool message history (accumulated)
- tools array (function definitions)
→ Vapi POSTs to LLM provider (OpenAI / Anthropic)
→ LLM responds with:
- text → Vapi speaks via TTS → caller hears response
- tool_call → Vapi POSTs the tool call to the per-tool server.url
→ Avoca handler runs, returns result
→ Vapi feeds result back into LLM
→ loop continues until LLM emits textLLM context per turn
Every turn, the LLM sees:
| Element | Source |
|---|---|
| System prompt | assistantConfig.systemPrompt (or sub-agent's prompt in MULTI_AGENT mode) |
| Conversation history | All prior bot, user, tool_call, tool_call_result messages |
| Tools array | assistantConfig.tools with names, descriptions, parameter schemas |
| Model knobs | temperature, maxTokens, etc. |
There is no hidden state. Every LLM call is stateless from the LLM's perspective; Vapi reconstructs the full context each turn.
Important footgun — the prompt repeats every turn
When grepping Vapi logs for an agent phrase, you'll find that phrases from the system prompt appear in every LLM request (because the prompt is sent fresh each turn). To find what the agent actually said, search for assistant.model.responseSucceeded events and read the completionText field. The prompt is messages[0].content in assistant.model.requestStarted events.
Tool call routing — where dispatches actually go
When the LLM emits a tool_call, Vapi POSTs to the tool's own server.url (priority 1 in Vapi's resolution stack), not the assistant's serverUrl. Each tool has its URL configured at agent-build time via FunctionTool → buildUrl(path), which reads process.env.NGROK_BASE_URL (defaults to https://app.avoca.ai).
For an FDE running locally with NGROK_BASE_URL=<tunnel>, tool calls land at <tunnel>/api/vapi/tools/dispatch?isTest=true. For production traffic, they land at app.avoca.ai/.... See vapi-squads.md for the priority hierarchy footgun.
In your test call
- Turn 0 (bot's static first message — no LLM)
- Turn 1-4 (user assembles "Hi. I need to reschedule an appointment.")
- First LLM request — system prompt + bot's greeting + user's "Hi I need to reschedule"; aborted (user kept speaking)
- Turn 5-9 (user finishes: "My appointment is May twentieth at, uh, eight AM.")
- Second LLM request — full context now, LLM decides to call
autoOpsLookupCustomer({})(empty args; the handler uses implicit caller-ID) - Tool dispatch — POST to
https://avoca-eas-bot.emerge.pizza/api/vapi/tools/dispatch?isTest=true, handler returns{ customers: [], message: "No AutoOps customers found." } - Third LLM request — LLM sees the empty result, prompt says "If no customer is found, then collect their first and last name" → agent says: "I'm not seeing an account pulled up just yet. Can I get your first and last name?"
That last turn is the bug we identified — the prompt has no "ask for a different phone number" step before falling back to name collection.
Branch: text response vs tool_call
Every LLM completion is one of:
- Text → spoken via TTS, conversation continues with user's next turn
- Tool call → dispatched to per-tool URL, result fed back to LLM, LLM produces next completion
Could be many tool_calls per user turn if the LLM keeps deciding it needs more info before speaking.
Where to look if it broke
| Symptom | Look at |
|---|---|
| Agent said the wrong thing | LLM completion in Vapi call log → assistant.model.responseSucceeded events |
| Agent didn't call a tool when it should | System prompt content (rules about when to call). The LLM follows the prompt. |
| Tool returned wrong data | Avoca dev server log (local) or Datadog (prod) for /api/vapi/tools/dispatch |
| Tool didn't fire at all | Vapi tool call event present? If not, the LLM didn't emit it. If present but no result, check assistant.tool.completed events. |
| Wrong sub-agent active (MULTI_AGENT only) | Check sub-agent transition events; check hand-off rules in multi_agent_config |
Stage 4: End of call (call.ended → end-of-call-report)
What happens
- Caller hangs up (or agent ends the call with an end-call phrase).
- Vapi emits
call.endedevent. - Vapi POSTs
messageType: end-of-call-reportto the sub-agent'sassistant.server.url(priority 2 in MULTI_AGENT, or the assistant'sserver.urlfor single-agent). The payload includes the full transcript, summary, structured messages, and recording URL. - Avoca's responder receives the end-of-call-report event, validates, and schedules an Inngest function (
EndOfCallReportInngestFunction) for post-call processing. - Responder returns 200. Vapi is done.
The post-call workflow runs asynchronously in Inngest — Vapi doesn't wait for it.
Where does the end-of-call-report go?
For MULTI_AGENT: sub-agent's assistant.server.url (set by test-phone-sync via buildTestPhoneServerUrl reading RESPONDER_COMMON_WORKFLOW_URL). For single-agent: assistant's server.url. Either way: the same /api/responder/common/workflow endpoint we used for assistant-request. The endpoint multiplexes on messageType. See gotchas.md F1.
In your test call
time: 1778462137817— call.ended event,endedReason: customer-ended-call, duration 40,949 ms- Then Vapi POSTed
end-of-call-reportto your tunnel's/api/responder/common/workflow - Your responder enqueued an Inngest function
Where to look if it broke
- Vapi call log →
call.endedevent withendedReason - Avoca dev server log →
POST /api/responder/common/workflowwithmessageType=end-of-call-report - Datadog → same filter for prod traffic
- Inngest dashboard → was the function scheduled?
Stage 5: Post-call workflow
What happens
Inngest fires EndOfCallReportInngestFunction with the call's end-of-call payload. The workflow:
- Vertical triage — routes by
assistant_configs.vertical:AUTO_SERVICE→AutoServiceWorkflowRunTriager(EAS Ponderosa lives here)WINDOW,FLOORING,JUNK_REMOVAL,ROOFING, defaultHomeServicesWorkflowRunTriagerfor others
- CRM triage — within
AUTO_SERVICE, switches ontypedConfig.crm:AUTO_OPS→runAutoOpsBookingWorkflow(only CRM under AUTO_SERVICE today)
- LLM extraction —
runAutoServiceExtractionclassifies the call'scallReason(Booking | Rescheduling | Cancellation | ...) plus other structured fields (appointment time, etc.) - Action dispatch — branches on
callReason:
runAutoOpsBookingWorkflow
├── callReason: Booking + appointmentBooked
│ └── runBooking → AutoOps POST /book → success
├── callReason: Rescheduling
│ ├── extract existing jobId via getExistingAutoOpsJobIdFromTranscript (LLM)
│ ├── if found: rescheduleAutoOpsAppointment → AutoOps PATCH /jobs/{id}/reschedule
│ └── if not found: graceful failure → email shop
└── callReason: Cancellation
├── extract existing jobId
├── if found: cancelAutoOpsAppointment → AutoOps POST /jobs/{id}/cancel
│ [BUT: 4xx for SMS-typed customers — PERMANENT product policy]
│ [Graceful failure → email shop with [AUTOOPS ERROR] subject]
└── if not found: graceful failure → email shop- Side effects —
callstable updated,sendAutoServiceEmailnotifies the shop, recording stored.
⚠️ What we are NOT doing — in-call cancel
The in-call cancel tool was removed (PR #10014, merged 2026-05-09). Cancels go through the post-call workflow only. This is intentional: the in-call surface created risk of the agent canceling test jobs out from under us, and the post-call workflow is the right shape for mutations (Inngest retry safety, idempotency key support).
⚠️ What we are NOT doing — mid-call mutations
By design, the in-call agent never mutates AutoOps state. All in-call tools are read-only: autoOpsLookupCustomer, autoOpsGetJobs, autoOpsGetServices, autoOpsGetAvailability. Mutations (book, reschedule, cancel) live in the post-call workflow. This split exists because in-call mutations don't compose with Inngest retry semantics — if the call drops mid-tool-execution, you'd want to retry the mutation, but the LLM doesn't know that.
In your test call
Your call ended with callReason: Rescheduling (based on user's utterance), but since the caller wasn't an AutoOps customer, there was no existing jobId to extract. The reschedule branch would have fallen through to "email the shop" graceful failure. You can verify this in Inngest dev UI at http://localhost:8288 — look for the EndOfCallReportInngestFunction run keyed by your callId.
Where to look if it broke
- Inngest dashboard → function run for this callId. Step graph shows where it stopped.
- Datadog → trace by callId across the workflow stages
callstable →data_storeJSON has the workflow'sprocessResult
Stage 6: Persistence
What happens
callstable (Supabase) — call row updated with transcript, summary,vapi_call_id, status,data_storeJSON- Recording — Vapi stores audio; Avoca may copy to its own storage
- Datadog — call's spans and logs retained per Datadog retention policy
In your test call
calls table has a row keyed by vapi_call_id = 019e1499-fb08-7002-9717-ad177aa8e2db. Since this was a test phone (is_test_phone = true), shouldHideCall = true set in prepare.ts → the call doesn't appear in Avoca's default call-log UI. That's intentional — keeps test calls out of the team's reporting.
Test calls don't show up in Avoca's call log
Because is_test_phone = true triggers shouldHideCall = true (lib/call/prepare.ts:119). The call DID happen and IS in the DB — just hidden from the UI. To find a test call, use the Vapi dashboard directly or query Supabase by vapi_call_id.
What we're NOT doing today — consolidated
A summary of the design choices and gaps surfaced through this walkthrough:
| Feature | Status | Reason / Next step |
|---|---|---|
| Pre-call customer enrichment (caller-ID lookup + variable injection) | Not implemented | Tradeoff between personalization and call-start latency. Worth a design proposal if Avoca wants it. See Stage 1 callout. |
| In-call cancel tool | Disabled (#10014, merged 2026-05-09) | Cancels handled post-call only. Mid-call cancel created risk of agent canceling test jobs accidentally. |
| Mid-call AutoOps mutations | By design, never | In-call tools are read-only; mutations live in post-call workflow with retry/idempotency semantics. |
| SMS-cancel via AutoOps API | Blocked at AutoOps (permanent product policy) | Graceful failure → email shop with [AUTOOPS ERROR] subject. Documented in gotchas.md. |
| Mirror sync (legacy AutoOps caching) | Being retired (Plan J in flight, PR #10162) | Direct AutoOps API now efficient enough; mirror was overhead. |
| "Ask for different phone number" before name fallback | NOT in current prompt (the bug being fixed in Plan K) | One-line prompt edit; pending design conversation. |
Quick reference — where each piece lives
| Question | Answer |
|---|---|
| What's the prompt the agent saw on this turn? | Vapi call log → first assistant.model.requestStarted event → messages[0].content |
| What did the agent say on this turn? | Vapi call log → assistant.model.responseSucceeded → completionText |
| What did the caller say? | Vapi call log → assistant.transcriber.finalTranscript → transcript |
| What tools fired, with what args, with what results? | Vapi call log → assistant.tool.started + assistant.tool.completed |
| What URL did the tool dispatch hit? | Each tool call event has serverUrl |
| What did Avoca's tool handler do? | Avoca dev server log (local) or Datadog (prod) for /api/vapi/tools/dispatch |
| Did pre-call enrichment run? | Avoca dev server log or Datadog → assistant-request response body. Also: {{ }} variables visible in the LLM prompt? |
| Did the post-call workflow fire? | Inngest dashboard → EndOfCallReportInngestFunction for this callId |
| What did the workflow do step by step? | Inngest run's step graph |
| Where's the call record? | Supabase calls table by vapi_call_id |
| Where's the audio recording? | Vapi dashboard call view → recording URL; also persisted via Avoca's storage |
Related pages
- In-call sequence — deeper dive on the LLM loop (Stage 3)
- Post-call dispatch chain — deeper dive on the post-call workflow (Stage 5)
- Vapi squads — sub-agent architecture for MULTI_AGENT assistants
- Agent architecture: legacy vs current — context on the dynamic resolution pattern
- AutoOps integration — the AutoOps client + tool handlers
- Gotchas — F1 covers the workflow-endpoint multiplexer; other entries cover SMS-cancel + test-phone hiding
- Call lifecycle playbook — diagnostic playbook keyed to stages (sister doc to this one — when something breaks)
- Local dev environment setup — how to make Stages 1-5 land on your localhost