Appearance
Code Path: Live AutoOps vs Mock AutoOps
Full technical walkthrough of how a single call routes through avoca-next — twice. First showing the live AutoOps path (real CRM, production-shape), then the mock AutoOps path (our WireMock substrate). Same call shape, same agent, same tools. The two paths diverge at exactly two points: getAutoOpsConfig (short-circuit vs Supabase + decrypt) and the URL constant inside the AutoOps client.
This page exists so questions like "where does decryption happen?" or "is Supabase queried in the mock path?" can be answered by tracing the diagram, not by re-asking.
Reading guide
- Two stages per path: in-call (tool invocations during the live conversation) and post-call (Inngest workflow that runs after Vapi hangs up).
- File paths in callouts use
next/...since that's how the canonical clone is mounted in the toolkit. - Divergence points are highlighted in red boxes / notes.
- Both diagrams assume the same starting condition: a Hamming-driven test call to a test phone bound to one of the EAS teams.
In-Call Code Path — LIVE AutoOps
Configuration:
AUTOOPS_BASE_URL = 'https://api.autoops.com/v1'(production default inapps/web/lib/autoops/autoops.ts:20)MOCK_AUTOOPS_CONFIGS = [](no short-circuit; production has no test-team hardcodes)WEBHOOK_API_KEY_ENCRYPTION_KEYenv var IS set (so decryption succeeds)- Team's
autoops_team_configs+autoops_credentialsrows exist in Supabase
Architecture (component topology):
Divergence components highlighted: red AUTOOPS_BASE_URL (the URL constant that gets swapped on test branches) and yellow getAutoOpsConfig (the function where the short-circuit branch is inserted on test branches).
Time-ordered sequence (same flow, step by step):
What ran on the real backend: the GET hit api.autoops.com. The shop's actual schedule was read. Real customer records, real services, real availability.
What touched Supabase: one row read from autoops_team_configs JOINed with autoops_credentials.
What got decrypted: the team's apiKey (one AES-256-GCM operation per getAutoOpsConfig call, per tool invocation).
In-Call Code Path — MOCK AutoOps
Configuration (our test worktree test/mock-autoops-southpark):
AUTOOPS_BASE_URL = 'https://api-mocker.dft.dawn'(hardcoded override on this branch)MOCK_AUTOOPS_CONFIGS = [...]includes teams 2970 / 2977 / 2976 with hardcoded plaintext apiKeys + real AutoOps clientIds (soshop-routing.jsonworks)WEBHOOK_API_KEY_ENCRYPTION_KEYenv var is NOT set locally (and that's fine because we never need to decrypt)- Supabase rows may or may not exist for the test teams (irrelevant either way)
Architecture (component topology):
Differences from the live diagram, at a glance:
- Green components are the mock-specific divergences (
MOCK_AUTOOPS_CONFIGSarray,AUTOOPS_BASE_URLpointing at WireMock). - Faded grey, dashed components (Supabase tables,
WEBHOOK_API_KEY_ENCRYPTION_KEY) are present in the codebase but never reached in this path. The short-circuit returns BEFORE they would be touched. - The HTTP destination is WireMock instead of real AutoOps.
- The
decryptApiKeynode disappears entirely (no encrypted credentials to unwrap).
Time-ordered sequence:
What ran on the real backend: nothing. Zero traffic to api.autoops.com.
What touched Supabase: nothing. getAutoOpsConfig short-circuited before the query.
What got decrypted: nothing. decryptApiKey was never called. WEBHOOK_API_KEY_ENCRYPTION_KEY is irrelevant in this path.
What WireMock saw: one GET, with the real EAS clientId in the path, an Authorization header containing the literal string "Bearer mock-key", and an empty query/body. WireMock matched the stub URL pattern and returned canned JSON.
In-Call Divergence Summary
| Step | Live AutoOps path | Mock AutoOps path |
|---|---|---|
| 1. dispatcher | identical | identical |
| 2. tool-call-mock lookup | identical (no mock configured) | identical (no mock configured) |
| 3. real handler entry | identical | identical |
4. getAutoOpsConfig(teamId) | falls through to Supabase | returns hardcoded mock config (short-circuit) |
| 5. Supabase query | runs (1 row) | never runs |
6. decryptApiKey | runs (requires WEBHOOK_API_KEY_ENCRYPTION_KEY) | never runs |
| 7. AutoOps client construction | identical | identical |
| 8. HTTP request destination | https://api.autoops.com/v1 | https://api-mocker.dft.dawn |
| 9. response handling | identical | identical |
Two divergence points. Everything else is bit-for-bit the same code path.
Post-Call Code Path — LIVE AutoOps
After Vapi hangs up, the end-of-call webhook fires. Eventually that lands in an Inngest function which calls the booking stage. The booking stage repeats the same getAutoOpsConfig → AutoOps client chain we just saw.
Architecture (component topology):
A real booking is created in EAS's AutoOps tenant. A real job_* ID comes back.
Time-ordered sequence:
A real booking is created in EAS's AutoOps tenant. A real job ID, customer ID, vehicle ID come back. If you don't want this to land in real prod data, cancel the job immediately afterward (per CLAUDE.md gotcha: "schedule a job, grab its id, cancel/reschedule that id").
Post-Call Code Path — MOCK AutoOps
Same Vapi end-of-call event. But RESPONDER_COMMON_WORKFLOW_URL is rewritten by Plan P's overlay to derive from NGROK_BASE_URL, so common-webhook traffic lands on the LOCAL Next.js process (not Avoca's hosted common-webhook). Local Inngest dev picks it up. Same workflow, same booking-autoops stage. But getAutoOpsConfig short-circuits, and the URL is hardcoded to WireMock.
Architecture (component topology):
Three transport-layer divergences from the live post-call diagram (in addition to the config short-circuit and URL constant):
- ngrok tunnel replaces direct Vapi-to-prod routing
- common-webhook route runs on local avoca-next instead of Avoca's hosted endpoint
- Local Inngest dev picks up events instead of Inngest cloud
All three are enabled by the Plan P overlay (af78a5c89e in worktree-configs/avoca-next.json) that rewrites RESPONDER_COMMON_WORKFLOW_URL to derive from NGROK_BASE_URL.
Time-ordered sequence:
No real booking is created. The mock returns synthetic IDs (mock-job-VNDMK3DO etc.). Nothing reaches api.autoops.com. Nothing reaches EAS's real tenant.
Post-Call Divergence Summary
| Step | Live AutoOps path | Mock AutoOps path |
|---|---|---|
| 1. Vapi end-of-call webhook URL | Avoca-hosted prod common-webhook | Local Next.js via ngrok tunnel (Plan P overlay rewrites) |
| 2. Inngest event routing | Inngest cloud | Local Inngest dev |
| 3. Workflow triage | identical | identical |
| 4. booking-autoops stage entry | identical | identical |
5. getAutoOpsConfigByClientId | falls through to Supabase | returns hardcoded mock config (clientId-keyed short-circuit) |
| 6. Supabase query | runs (1 row, by client_id) | never runs |
7. decryptApiKey | runs | never runs |
| 8. AutoOps client construction | identical | identical |
9. book POST destination | https://api.autoops.com/v1 | https://api-mocker.dft.dawn |
| 10. response handling | identical | identical |
Zoomed-in views of the two divergence points
Divergence 1: getAutoOpsConfig short-circuit
Source: next/apps/web/lib/supabase/autoops.ts on the test/mock-autoops-southpark branch.
ts
const MOCK_AUTOOPS_CONFIGS: AutoOpsConfig[] = [
{
teamId: 2970,
apiKey: 'mock-key',
clientId: 'cl_e019c08b5b3a49b5b7a990e67ceb3e6f', // real AutoOps clientId
enabled: true,
clientName: 'TEST - EAS - South Park',
clientKey: null,
timeZone: 'America/Denver',
},
// ... Platte Canyon (2977), Chatfield (2976) ...
];
export async function getAutoOpsConfig(
teamId: number,
log?: Logger
): Promise<AutoOpsConfig | null> {
const mock = MOCK_AUTOOPS_CONFIGS.find((c) => c.teamId === teamId);
if (mock) return mock; // ← short-circuit. Supabase + decrypt never run.
// Production code path below — Supabase query, decrypt, etc.
// ...
}
export async function getAutoOpsConfigByClientId(
clientId: string,
log?: Logger
): Promise<AutoOpsConfig | null> {
const mock = MOCK_AUTOOPS_CONFIGS.find((c) => c.clientId === clientId);
if (mock) return mock; // ← same short-circuit, clientId-keyed
// Production code path below.
// ...
}Both keyings exist because the in-call flow looks up by teamId (caller's team) and the post-call booking flow looks up by clientId (the chosen shop, which may differ from the caller's team in cross-shop bookings).
What lives in the production code on main: neither the MOCK_AUTOOPS_CONFIGS array nor the if (mock) return mock; lines. Just the Supabase + decrypt path. The short-circuit is added only on test branches.
Divergence 2: AUTOOPS_BASE_URL constant
Source: next/apps/web/lib/autoops/autoops.ts on the test/mock-autoops-southpark branch.
ts
const AUTOOPS_BASE_URL = 'https://api-mocker.dft.dawn'; // ← test branch only
// In production (main), this line reads:
// const AUTOOPS_BASE_URL = 'https://api.autoops.com/v1';The constant is module-level and read by every method of the AutoOps class (5 references at the time of writing). One literal-string change on the test branch reroutes every AutoOps HTTP call from this process to WireMock.
Important property: this is a process-global setting, not per-call. Every team's AutoOps traffic from this process goes to the destination this constant points at. There is no current mechanism to route some teams to real AutoOps and other teams to WireMock from the same process. That kind of split would require a different architecture (e.g., per-call baseUrl override on the AutoOps constructor, with the decision driven by an isTest signal).
Why decryption is irrelevant in the mock path
Reading the divergence diagrams together: decryption only matters when getAutoOpsConfig falls through to the Supabase branch. On the mock path, the short-circuit returns BEFORE Supabase is ever queried, so the encrypted apiKey is never read, and decryptApiKey is never invoked.
This means our local dev setup does NOT need WEBHOOK_API_KEY_ENCRYPTION_KEY for any team in MOCK_AUTOOPS_CONFIGS. For any team NOT in that array, calls would attempt the Supabase + decrypt path and fail because the env var isn't set — getAutoOpsConfig would return null and the call would bail before any HTTP request fires.
Practical implication for the "live enterprise via mock" use case: to route a real EAS team's calls through WireMock, add that team's entry to MOCK_AUTOOPS_CONFIGS with Kareem's plaintext apiKey + the real clientId. The short-circuit then bypasses Supabase + decrypt for that team too, the URL constant routes the HTTP to WireMock, and you can run a batch against the real enterprise's identity without needing the encryption key. The team's actual Supabase row stays untouched.
File index (one-stop reference)
| Concern | File |
|---|---|
URL constant + AutoOps class | apps/web/lib/autoops/autoops.ts |
| Team config resolution + short-circuit | apps/web/lib/supabase/autoops.ts |
| Decryption (real path) | apps/web/lib/api-keys/encryption.ts |
| Tool dispatcher | apps/web/pages/api/vapi/tools/dispatch.ts |
| Per-tool mock layer (separate from this) | apps/web/lib/tools/test-call-mock.ts |
| Availability tool handler | apps/web/lib/vapi/tools/handlers/autoOpsGetAvailability.ts |
| Booking commit tool (in-call, no-op) | apps/web/lib/vapi/tools/handlers/autoOpsConfirmAppointment.ts |
| Booking stage (post-call) | apps/web/lib/workflow/stages/booking-autoops/run-booking-autoops.ts |
| Test-phone server URL (Plan P overlay) | apps/web/lib/voice-assistants/test-phone/provision-test-phone.ts |
| WireMock instance | apps/api-mocker/ |
| WireMock stub mappings | apps/api-mocker/mappings/autoops/ |
Related
- Mock CRM API (HTTP-level) — higher-level architecture and design rationale (the "why this layer exists")
- Tool Call Mocks — the per-tool mock layer (separate concern; runs upstream of
AutoOps) - Hamming Features — orchestration features (Test Hooks, validation webhooks) we may lean on
- EAS Replica Setup — how the test enterprise + test shops were provisioned