Appearance
How to set up a local dev environment
Goal: get to a state where a real Vapi call to your designated test phone routes its tool dispatches through your local dev server (so your branch-level code actually executes during in-call agent behavior).
This guide is opinionated and grounded in the actual code paths. If something doesn't match what you see, check the common failures section before pinging the team — most surprises here are well-known.
Don't do this on the prod phone
You do NOT modify the production phone's routing to point at your tunnel. That breaks customer-facing calls. The whole pattern below is about provisioning a separate test phone with its own routing wired to you.
The mental model
There are two phones for any voice assistant you'd touch as an FDE:
- Prod phone — the real number customers call. Twilio → Avoca prod webhook → Vapi → tools execute against prod URL. Hands-off.
- Test phone — a sibling Twilio number you provision once for FDE work. With the right env vars set + a test-phone sync, two distinct URL surfaces in Vapi point at your tunnel: assistant-level
server.url(where workflow events go) and per-toolserver.url(where tool dispatches go). Both must be redirected — they cover different traffic.
Each voice assistant has a voice_assistants.test_inbound_call_phone_id column linking it to its dedicated test phone. If that column is null, you need to provision one (one-time setup). If it has a value, you can go straight to syncing.
The two-surface gotcha (read this before anything else)
The thing that bites every new FDE: redirecting just the assistant-level URL is not enough. Per-tool URLs are a separate surface and they're configured at agent-build time, not at sync time.
Vapi resolves where to POST a tool dispatch by walking three priority levels, highest first:
- Per-tool
server.url(highest — set on each function tool's config in the squad payload) - Sub-agent
assistant.server.url(mid — set per-sub-agent) - Phone-number
server.url(lowest — set on the Twilio phone number's Vapi binding)
Avoca's tools all carry their own per-tool server.url, so priority 1 wins for every tool dispatch. Sub-agent and phone-level URL changes do nothing for tool calls.
Two distinct levers redirect the two surfaces — both are env vars, both have to be set:
| Env var | Code path that reads it | URL surface it controls |
|---|---|---|
NGROK_BASE_URL | buildUrl() in lib/voice-assistants/utils.ts | Per-tool server.url (priority 1) — every FunctionTool.getVapiOpenAIModelTool calls buildUrl(this.path) at agent-build time |
RESPONDER_COMMON_WORKFLOW_URL | buildTestPhoneServerUrl in provision-test-phone.ts | Sub-agent assistant.server.url (priority 2) — what test-phone-sync bakes into every sub-agent |
TEST_PHONE_POST_CALL | provision-test-phone.ts | Whether postCall=true is stamped on the test-phone server URL + Twilio voice URL (default false — flip to true if you want the post-call workflow to fire on test calls) |
If you set only RESPONDER_COMMON_WORKFLOW_URL: workflow events (conversation-update, end-of-call-report) reach your tunnel, but tool dispatches still hit prod. The agent will work but its responses will reflect prod's tool handlers, not your branch.
If you set only NGROK_BASE_URL: tool dispatches reach your tunnel, but workflow / end-of-call events still hit prod. Tool changes show up; post-call workflow doesn't run locally.
You almost always want both.
Why a separate phone
You don't modify the prod phone — that would break customer-facing calls. You provision a dedicated test phone tied to the same voice assistant. Avoca builds a test squad derived from the production assistant config, with your tunnel URLs baked into both surfaces. The prod phone is left untouched.
The apps/web/lib/voice-assistants/test-phone/sync-test-phone.ts comment captures the priority-2 piece:
Bake the test server URL into every squad member's
assistant.server.url(priority level 2 in VAPI's stack). The phone-level URL alone (priority 3) is silently shadowed if any sub-agent grows aserverUrl, so we set the higher-priority slot ourselves on this dedicated test squad.
But priority-1 (per-tool URLs) is not rewritten by sync — it's set when the agent is built in-process by AgentFactory, which happens during getVapiAssistantReq(). That's why NGROK_BASE_URL matters: it's read at build time, so the per-tool URLs in the squad payload pushed to Vapi already point at your tunnel.
The setup recipe
Step 1 — Cloudflare tunnel pointing at your local dev server
bash
cloudflared tunnel --url http://localhost:3000Or use a named tunnel for stability across sessions. Either way, you'll have a public hostname like https://<random>.trycloudflare.com that forwards to localhost:3000. Keep that running in a terminal — your tunnel is your URL.
Step 2 — ALLOWED_DEV_ORIGINS
Next.js 16 blocks cross-origin requests by default. Add your tunnel hostname to apps/web/.env.local:
bash
ALLOWED_DEV_ORIGINS=${random}.trycloudflare.com(apps/web/next.config.js reads this env var into allowedDevOrigins[] per-developer.)
Step 3 — Patch RESPONDER_COMMON_WORKFLOW_URL to be env-driven
apps/web/lib/constants/responder.ts currently hardcodes the responder webhook URL to prod. The test-phone provisioner uses this constant when computing the assistant-level server.url to bake into the test squad. To make it point at your tunnel, change:
typescript
export const RESPONDER_COMMON_WORKFLOW_URL =
'https://app.avoca.ai/api/responder/common/workflow';to:
typescript
export const RESPONDER_COMMON_WORKFLOW_URL =
process.env.RESPONDER_COMMON_WORKFLOW_URL ??
'https://app.avoca.ai/api/responder/common/workflow';Don't commit this patch
This is currently a local-only edit. The upstream env-var support is a tiny PR (~5 lines) that we should land separately so every engineer doesn't repeat the patch. Until then: keep the change in your worktree, do not include it in any feature PR. (See Plan P — feat/fde-local-dev-unblock.)
Why NGROK_BASE_URL doesn't need a code patch
The other env var (NGROK_BASE_URL) is already read by buildUrl() in lib/voice-assistants/utils.ts. You don't have to patch any source — it's a free env-var slot. Only RESPONDER_COMMON_WORKFLOW_URL needs the constant rebound until Plan P merges.
Step 4 — Add the env vars to apps/web/.env.local
Set the trio that redirects all FDE-relevant URL surfaces to your tunnel:
bash
# Per-tool server.url (priority 1) — controls where tool dispatches go.
# Already env-overridable in main; no code patch needed.
NGROK_BASE_URL=https://${random}.trycloudflare.com
# Sub-agent assistant.server.url (priority 2) — controls workflow events.
# Requires the Step 3 code patch until Plan P merges.
RESPONDER_COMMON_WORKFLOW_URL=https://${random}.trycloudflare.com/api/responder/common/workflow
# Optional: stamp postCall=true on the test-phone server URL so the
# post-call workflow fires on test calls. Default is false. Only flip
# to true if you're using a test customer / test job you control.
TEST_PHONE_POST_CALL=trueUse the same hostname as your ALLOWED_DEV_ORIGINS.
Both NGROK_BASE_URL and RESPONDER_COMMON_WORKFLOW_URL are required
Setting only one is the most common stuck-state. Tool dispatches need NGROK_BASE_URL (per-tool URLs are priority 1). Assistant-level events need RESPONDER_COMMON_WORKFLOW_URL. They cover different traffic — see The two-surface gotcha above.
Step 5 — Restart your dev server
bash
# from the toolkit
pnpm wt <your-worktree-slug>:web devHot-reload doesn't always pick up new env vars or constant rebindings cleanly — restart for confidence.
Step 6 — Provision (or sync) the test phone
If the voice assistant already has a test phone (voice_assistants.test_inbound_call_phone_id is non-null), skip provisioning and just sync:
- Avoca admin → Voice Assistants → your assistant → click Sync test phone.
- This invokes
syncTestPhoneAction→syncTestPhoneForVoiceAssistant→AgentFactory.createAgentFromVoiceAssistantId(which readsNGROK_BASE_URLat build time to set per-tool URLs) →agent.getVapiAssistantReq({ serverUrlOverride })(which usesbuildTestPhoneServerUrlreadingRESPONDER_COMMON_WORKFLOW_URL) → pushes the resulting squad payload to Vapi. - Result: the squad in Vapi cloud now has both surfaces pointing at your tunnel.
If there's no test phone yet, you need to provision one first:
- Avoca admin → Voice Assistants → your assistant → click Configure test phone (or whatever the affordance is — wired to
configureNewTestPhoneinprovision-test-phone.ts). - This creates a dedicated Twilio number, links it via
test_inbound_call_phone_id, configures its Twilio voice URL, and runs the initial sync.
Find the test phone's number
You'll need it to call. Either:
Look at the voice-assistant detail page in admin — it typically shows the test phone alongside the prod one.
Or query Supabase directly:
sqlSELECT pn.phone_number, va.name FROM voice_assistants va JOIN phone_numbers pn ON pn.id = va.test_inbound_call_phone_id WHERE va.id = '${voiceAssistantId}';
Step 7 — Run the Inngest dev server
bash
# in another terminal, from the toolkit
pnpm wt <your-worktree-slug> inngestVisit http://localhost:8288 to confirm it lists your registered functions (EndOfCallReportInngestFunction, etc.). After your test call ends, post-call workflow runs appear here.
Step 8 — Place the call
Call the test phone number (NOT the prod number) from your phone. Drive the agent through whatever change you're testing.
Step 9 — Verify it actually routed to you
Two surfaces, two signals — check both:
- Tool dispatches → your tunnel. Dev server logs show
POST /api/vapi/tools/dispatchlines during the call. Each tool the agent invokes hits this endpoint on your local. If you see zerotools/dispatchlines,NGROK_BASE_URLisn't taking effect — see "tool dispatches still go to prod" below. - Workflow events → your tunnel. Dev server logs show
POST /api/responder/common/workflowlines (one permessageTypeVapi emits during the call:assistant-request,speech-update,conversation-update,end-of-call-report). If you see zero,RESPONDER_COMMON_WORKFLOW_URLisn't taking effect. - Tool result correctness check. Once tool dispatches land, verify they hit your branch's code: tool result responses should NOT contain prod-only fields. (For the autoops-sync-migration branch specifically, no
source: "autoops_mirror".) If results still look prod-shaped, your local server isn't running the branch you think it is. - Inngest UI at http://localhost:8288 shows the post-call run materializing within seconds of hangup (only when
TEST_PHONE_POST_CALL=true). Step into the run; verify the right step ran with the input/output you expect.
Common failures
Tool dispatches still go to prod (workflow events arrive locally but tools/dispatch doesn't). Most common cause: NGROK_BASE_URL not set or not picked up by the dev server. Per-tool URLs are baked at agent-build time, so:
- Confirm
NGROK_BASE_URLis inapps/web/.env.local. - Restart the dev server — env vars only load at startup.
- Re-run test-phone sync. The squad in Vapi has stale per-tool URLs from the previous build until you sync again.
- Confirm in the Vapi dashboard: open the test squad → expand any sub-agent → check each tool's
server.url. The hostname should match your tunnel, notapp.avoca.ai.
Workflow events go to prod (tools/dispatch arrives locally but no /api/responder/common/workflow). Inverse problem — RESPONDER_COMMON_WORKFLOW_URL isn't set, the constant patch from Step 3 didn't apply, or the sync ran before you set it.
- Confirm the constant patch (Step 3) is applied:
grep RESPONDER_COMMON_WORKFLOW_URL apps/web/lib/constants/responder.tsshould showprocess.env.RESPONDER_COMMON_WORKFLOW_URL ??. - Confirm the env var is set in
.env.local. - Restart dev server and re-run test-phone sync.
- Confirm in the Vapi dashboard: open the test squad → expand any sub-agent → check
assistant.server.url. Should be your tunnel.
syncTestPhoneAction fails with "no test phone linked". The voice assistant's test_inbound_call_phone_id column is null. You need to provision first via configureNewTestPhone (Step 6's "no test phone yet" branch).
syncTestPhoneAction returns 500 with PGRST116 / "Error fetching phone number or missing Twilio ID". The squad-update step succeeded (check the dev log for Updated existing test squad), but the trailing updateTwilioPhoneNumberWebhooks call uses an RLS-enforced Supabase client that can't see the test phone row. Avoca's been sweeping similar reads to serviceClient (PRs #10166, #10165, #10163) but missed this caller. The squad-update is what matters for your loop — ignore the 500 toast or skip-worktree the file with a createServiceClient() patch locally. Tracked for upstream fix.
Cloudflare tunnel URL changes between sessions. Ad-hoc cloudflared tunnel --url tunnels are ephemeral. Either restart the test-phone sync after each tunnel restart (annoying) or run a named tunnel with a stable hostname (recommended).
Vapi assistant picks up wrong assistant config. The test phone is wired to a test squad derived from the production assistant. If the production assistant config changes, the test squad will drift unless re-synced. Click Sync test phone after any production-assistant edit you want reflected in your test calls.
@avoca/observability or @googleapis/* module-not-found at dev startup. You probably skipped a pnpm install after a recent git pull of main. Stop the dev server, run pnpm install --frozen-lockfile from the worktree root, then restart.
What this gives you
- A real call to a real Twilio number, executed by a real Vapi squad, hitting your local code for tool dispatches.
- Inngest dev UI for post-call workflow inspection.
- The ability to put
console.log/ breakpoints / fresh tool implementations in your branch and see them exercised by an actual conversation in seconds.
If your change is a pure post-call workflow change (not in-call tool dispatch), you don't strictly need this — Vapi end-of-call webhooks hit /api/responder/common/workflow and your local will see them via the same env var. But if you're touching an in-call tool handler, this is the only loop that exercises your code in real conversation.
Going further
- In-Call Sequence — the turn-by-turn loop diagram of what's happening during a call. Read this if "why doesn't priority 2 work" is opaque.
- Prod Validation Runbook — after-merge ritual once your local-validated change is in prod.
- Dev Env Access — credentials for the various services this depends on (Vapi, Twilio, Supabase, etc.).
Future improvements
- Upstream the env-var support for
RESPONDER_COMMON_WORKFLOW_URLso engineers don't have to maintain a local-only patch. ~5-line PR (Plan P). - Collapse the trio to one base-URL env var.
RESPONDER_COMMON_WORKFLOW_URLcould be derived fromNGROK_BASE_URLviabuildUrl('/api/responder/common/workflow'), eliminating the second URL var. (TEST_PHONE_POST_CALLis independent — it's a flag, not a hostname.) - Rename
NGROK_BASE_URLto something tunnel-agnostic (e.g.FDE_TUNNEL_BASE_URLorLOCAL_DEV_BASE_URL). The current name implies ngrok specifically; in practice it works with any tunnel (Cloudflare, ngrok, Tailscale Funnel). - Sweep
updateTwilioPhoneNumberWebhookstocreateServiceClient()so the trailing Twilio update step insyncTestPhoneActiondoesn't 500 for FDE users. - Named Cloudflare tunnel pattern (stable hostname) documented as the default rather than ad-hoc.
- Datadog dashboard filter for "test calls" so we can find them in noise.
- Hamming on-demand sim recipe targeting the same test squad — bypasses the phone, useful for regression coverage of agent prompt changes.