Skip to content

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-tool server.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:

  1. Per-tool server.url (highest — set on each function tool's config in the squad payload)
  2. Sub-agent assistant.server.url (mid — set per-sub-agent)
  3. 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 varCode path that reads itURL surface it controls
NGROK_BASE_URLbuildUrl() in lib/voice-assistants/utils.tsPer-tool server.url (priority 1) — every FunctionTool.getVapiOpenAIModelTool calls buildUrl(this.path) at agent-build time
RESPONDER_COMMON_WORKFLOW_URLbuildTestPhoneServerUrl in provision-test-phone.tsSub-agent assistant.server.url (priority 2) — what test-phone-sync bakes into every sub-agent
TEST_PHONE_POST_CALLprovision-test-phone.tsWhether 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 a serverUrl, 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:3000

Or 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 Pfeat/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=true

Use 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 dev

Hot-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 syncTestPhoneActionsyncTestPhoneForVoiceAssistantAgentFactory.createAgentFromVoiceAssistantId (which reads NGROK_BASE_URL at build time to set per-tool URLs) → agent.getVapiAssistantReq({ serverUrlOverride }) (which uses buildTestPhoneServerUrl reading RESPONDER_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 configureNewTestPhone in provision-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:

    sql
    SELECT 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> inngest

Visit 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:

  1. Tool dispatches → your tunnel. Dev server logs show POST /api/vapi/tools/dispatch lines during the call. Each tool the agent invokes hits this endpoint on your local. If you see zero tools/dispatch lines, NGROK_BASE_URL isn't taking effect — see "tool dispatches still go to prod" below.
  2. Workflow events → your tunnel. Dev server logs show POST /api/responder/common/workflow lines (one per messageType Vapi emits during the call: assistant-request, speech-update, conversation-update, end-of-call-report). If you see zero, RESPONDER_COMMON_WORKFLOW_URL isn't taking effect.
  3. 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.
  4. 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:

  1. Confirm NGROK_BASE_URL is in apps/web/.env.local.
  2. Restart the dev server — env vars only load at startup.
  3. Re-run test-phone sync. The squad in Vapi has stale per-tool URLs from the previous build until you sync again.
  4. 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, not app.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.

  1. Confirm the constant patch (Step 3) is applied: grep RESPONDER_COMMON_WORKFLOW_URL apps/web/lib/constants/responder.ts should show process.env.RESPONDER_COMMON_WORKFLOW_URL ??.
  2. Confirm the env var is set in .env.local.
  3. Restart dev server and re-run test-phone sync.
  4. 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_URL so 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_URL could be derived from NGROK_BASE_URL via buildUrl('/api/responder/common/workflow'), eliminating the second URL var. (TEST_PHONE_POST_CALL is independent — it's a flag, not a hostname.)
  • Rename NGROK_BASE_URL to something tunnel-agnostic (e.g. FDE_TUNNEL_BASE_URL or LOCAL_DEV_BASE_URL). The current name implies ngrok specifically; in practice it works with any tunnel (Cloudflare, ngrok, Tailscale Funnel).
  • Sweep updateTwilioPhoneNumberWebhooks to createServiceClient() so the trailing Twilio update step in syncTestPhoneAction doesn'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.