Skip to content

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

  1. Caller dials the test phone (or production phone) number — a Twilio number.
  2. Twilio receives the inbound call.
  3. 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.
  4. Avoca's routing webhook reads assistant_configs.platform for this phone's voice assistant and decides:
    • platform = 'vapi' → respond with TwiML that hands the call to Vapi
    • platform = '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)
  • Datadogservice:avoca-next-prod filtered to /api/twilio/webhooks/inbound-call for this call
  • assistant_configs.platform — wrong column value means wrong handoff (e.g., the test phone's assistant is vapi, but if it was changed to elevenlabs the 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):

  1. 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.
  2. 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, via getActiveSimTestConfigIdForPhone).
  3. Standing test-config override — test calls only, an admin-set phone_numbers.assistant_config_id on the test number's own row (toggled via a FlaskConical icon next to the assistant-config list's "Default" star). Falls through when no run is active.
  4. Defaultvoice_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.tsxgenerateTeamTestPlan({ 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:

  1. Reads the assistant_configs row for this voice assistant
  2. Builds the assistant config (system prompt, tools, voice settings, etc.) via AgentFactory.createAgentFromVoiceAssistantId(...)
  3. Optional branch (currently NOT enabled for EAS): pre-call customer enrichment — see callout below
  4. 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/workflow with messageType=assistant-request
  • Datadog (when routing prod) — same filter, in the cloud
  • Vapi call objectassistant field shows what Vapi resolved as the assistant config. assistantOverrides.variableValues shows 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 assistant
  • assistant_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 message
  • time: 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 logpipeline.firstMessageStarted and pipeline.firstMessageCompleted events
  • Assistant config → the firstMessage field

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 text

LLM context per turn

Every turn, the LLM sees:

ElementSource
System promptassistantConfig.systemPrompt (or sub-agent's prompt in MULTI_AGENT mode)
Conversation historyAll prior bot, user, tool_call, tool_call_result messages
Tools arrayassistantConfig.tools with names, descriptions, parameter schemas
Model knobstemperature, 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 FunctionToolbuildUrl(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

SymptomLook at
Agent said the wrong thingLLM completion in Vapi call log → assistant.model.responseSucceeded events
Agent didn't call a tool when it shouldSystem prompt content (rules about when to call). The LLM follows the prompt.
Tool returned wrong dataAvoca dev server log (local) or Datadog (prod) for /api/vapi/tools/dispatch
Tool didn't fire at allVapi 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

  1. Caller hangs up (or agent ends the call with an end-call phrase).
  2. Vapi emits call.ended event.
  3. Vapi POSTs messageType: end-of-call-report to the sub-agent's assistant.server.url (priority 2 in MULTI_AGENT, or the assistant's server.url for single-agent). The payload includes the full transcript, summary, structured messages, and recording URL.
  4. Avoca's responder receives the end-of-call-report event, validates, and schedules an Inngest function (EndOfCallReportInngestFunction) for post-call processing.
  5. 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-report to your tunnel's /api/responder/common/workflow
  • Your responder enqueued an Inngest function

Where to look if it broke

  • Vapi call logcall.ended event with endedReason
  • Avoca dev server logPOST /api/responder/common/workflow with messageType=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:

  1. Vertical triage — routes by assistant_configs.vertical:
    • AUTO_SERVICEAutoServiceWorkflowRunTriager (EAS Ponderosa lives here)
    • WINDOW, FLOORING, JUNK_REMOVAL, ROOFING, default HomeServicesWorkflowRunTriager for others
  2. CRM triage — within AUTO_SERVICE, switches on typedConfig.crm:
    • AUTO_OPSrunAutoOpsBookingWorkflow (only CRM under AUTO_SERVICE today)
  3. LLM extractionrunAutoServiceExtraction classifies the call's callReason (Booking | Rescheduling | Cancellation | ...) plus other structured fields (appointment time, etc.)
  4. 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
  1. Side effectscalls table updated, sendAutoServiceEmail notifies 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
  • calls tabledata_store JSON has the workflow's processResult

Stage 6: Persistence

What happens

  • calls table (Supabase) — call row updated with transcript, summary, vapi_call_id, status, data_store JSON
  • 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:

FeatureStatusReason / Next step
Pre-call customer enrichment (caller-ID lookup + variable injection)Not implementedTradeoff between personalization and call-start latency. Worth a design proposal if Avoca wants it. See Stage 1 callout.
In-call cancel toolDisabled (#10014, merged 2026-05-09)Cancels handled post-call only. Mid-call cancel created risk of agent canceling test jobs accidentally.
Mid-call AutoOps mutationsBy design, neverIn-call tools are read-only; mutations live in post-call workflow with retry/idempotency semantics.
SMS-cancel via AutoOps APIBlocked 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 fallbackNOT in current prompt (the bug being fixed in Plan K)One-line prompt edit; pending design conversation.

Quick reference — where each piece lives

QuestionAnswer
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.responseSucceededcompletionText
What did the caller say?Vapi call log → assistant.transcriber.finalTranscripttranscript
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