Appearance
Call Lifecycle Diagnostic Playbook
Purpose. When a real call goes wrong, this page tells you exactly where to look. For each stage in the Responder call lifecycle, it names the file, the prompt, the trace filter, the table, and the most common failure mode.
Companions.
architecture-overview.md— what the system is. This page tells you what to do when it misbehaves.dev-env-access.md— credentials, smoke tests, dashboard URLs.integration-playbook.md— Avoca's captured Common-Webhook Playbook.
How to use this page
- Identify the stage where the call diverged from expectation. Use the Stage map below.
- For that stage, jump to its row in the Stage diagnostics table. Each row links to: file path, prompt source, trace filter, DB table, run page.
- If the failure matches a documented scenario (Customer lookup, Reschedule post-call), follow that scenario's walkthrough.
- If you need a quick-find script (latest call, trace open, run replay), see Scripts.
Stage map (Responder inbound call)
Verified on EAS pilot (Ponderosa shop, team 2678)
The stages below describe the Responder path used by the EAS Vapi assistant in production. Other CRM verticals (ServiceTitan, HCP, Jobber, Acculynx, Salesforce) follow the same shape with CRM-specific handlers swapped in.
Stage diagnostics table
Each row maps a single stage of the call lifecycle to the artifacts that observe it. File paths are relative to apps/web/ in the avoca-next repo unless otherwise noted. Datadog filters use the avoca-next-prod service; the dev environment uses avoca-next-dev.
| # | Stage | Where it lives | Where to watch | Common failure |
|---|---|---|---|---|
| 1 | Phone rings; Vapi answers | Vapi assistant config (Vapi dashboard, not in repo) | Vapi dashboard call list, filtered by assistant ID | Wrong assistant for the inbound number; pick-up but no transcript |
| 2 | Assistant evaluates system prompt + tools | Tool catalog: lib/voice-assistants/available-tools.ts; per-CRM tool sets: lib/voice-assistants/agents/agent-factory.ts. Prompt strings live in the Vapi dashboard, not the repo. | Vapi live transcript; Vapi tool-call events on the call page | LLM picks wrong tool, or no tool when one is needed; usually a prompt-tuning issue, not a code bug |
| 3 | Vapi POSTs tool-call webhook | Single dispatch route: pages/api/vapi/tools/dispatch.ts | Datadog: service:avoca-next-prod resource_name:"POST /api/vapi/tools/dispatch" @callId:<callId> | Wrong tool name → dispatcher 400; missing x-tool-name header on legacy routes |
| 4 | Dispatcher routes by function.name (or x-tool-name header) | lib/vapi/tools/dispatch-config.ts defines the registry; switch in pages/api/vapi/tools/dispatch.ts | Datadog log line dispatch tool:<toolName> | Tool not in DISPATCH_FUNCTION_NAMES registry → falls through to default case |
| 5 | Handler executes | lib/vapi/tools/handlers/<toolName>.ts (e.g., autoOpsLookupCustomer.ts) | Datadog: filter same trace, look at handler-internal spans / log lines | Handler-specific — see Scenario A and B |
| 6 | Handler → Supabase mirror lookup | lib/autoops/sync/lookup.ts (AutoOps), lib/service-titan/search-customer.ts (ServiceTitan), per-CRM equivalents | Supabase Studio → autoops_customers (or customers/st_customers); query by team_id + normalized_phone or ilike on names | Mirror sync stale (5-min lag for AutoOps team 2678); name-search splits on whitespace and uses ilike '%term%' — fails on exact-match expectations or hyphenated names |
| 7 | Handler → Live API fallback | lib/autoops/live-tools.ts (client.lookupCustomer) | Datadog HTTP span to api.autoops.com; or direct: curl -H "Authorization: ApiKey $AUTOOPS_API_KEY" https://api.autoops.com/v1/customers?phone=... | 403 → API key not enabled for that endpoint (handler returns graceful "not enabled" message); 404 → no match; live name match is stricter than ilike |
| 8 | Vapi result formatted + returned | lib/vapi/with-vapi-tool-call.ts (vapiResult) | Vapi transcript shows the tool result entry verbatim | Result not JSON-serializable; missing toolCallId → Vapi shows "tool error" |
| 9 | End-of-call webhook lands | pages/api/responder/.../end-of-call.ts (per-CRM) → emits an Inngest event named in lib/inngest/events/EndOfCallReportInngestEvent.ts | Datadog: service:avoca-next-prod resource_name:*end-of-call* @vapiCallId:<id>; Inngest dashboard: filter by event name | Vapi did not send end-of-call event (assistant hung up wrong); event sent but never reached our route (network) |
| 10 | Inngest receives + enqueues post-call workflow | lib/workflow/post-call/EndOfCallReportInngestFunction.ts (registered in app/api/inngest/route.ts) | Inngest dashboard run list, filtered by function id END_OF_CALL_REPORT_INNGEST_NAME; click into the run to see the step graph | Function paused, errored, or rate-limited; check the run's Inngest event payload |
| 11 | Workflow runs through stage triage | lib/workflow/stages/run/run-triage-home-service.ts — switches on row.type ('rescheduling', 'cancellation', 'eta', etc.) | Inngest run → step list shows each stage as a separate step | Wrong row.type for the call's intent → wrong stage runs; CRM-specific override (typedConfig.crm === 'FOUR_SEASONS') takes a separate branch |
| 12 | Stage runs and calls external API | Per-area stage runner, e.g., lib/workflow/stages/rescheduling/run-rescheduling.ts → currently always rescheduleSTAppointment (ServiceTitan only — see Scenario B) | Datadog HTTP span to the external API (AutoOps, ServiceTitan, etc.); Inngest run step output | CRM dispatch is missing for AutoOps in the rescheduling runner (Scenario B); call is silently a no-op for non-ServiceTitan teams |
| 13 | Stage writes results back to Supabase | Per-area utils.ts, e.g., lib/workflow/stages/rescheduling/utils.ts (updateTablesWithReschedulingInfo, updateTablesWithReschedulingResult) | Supabase Studio → calls row by id, fields: rescheduling, call_reason, data_store.reschedulingResult | Outer try/catch swallows DB errors with console.error only; failure invisible in the call row but loud in Datadog |
Scenario A — Customer lookup ("can't find me by name, can by phone")
The reference symptom: caller says "Avoca Test", assistant cannot find the account. Caller gives phone number; assistant finds the account immediately.
A.1 What happens
Tool: AUTOOPS_LOOKUP_CUSTOMER — function name autoOpsLookupCustomer. Defined in lib/voice-assistants/available-tools.ts:54. Read-scoped (no mutations).
Handler: lib/vapi/tools/handlers/autoOpsLookupCustomer.ts — autoOpsLookupCustomerHandler(ctx, req, res).
Prompt source: Vapi assistant system message + tool description in the Vapi dashboard. Not in the repo. Tool argument schema is on the Vapi tool definition itself; what the LLM passes is constrained by that schema.
Decision tree (handler):
- Read args from
ctx.message.toolCallList[0].function.arguments. Two named fields:phoneNumber?,nameSearch?. - Compute
phone = toAutoOpsPhone(args.phoneNumber ?? ctx.message.customer?.number). Strips non-digits. Falls back to caller ID if LLM didn't pass a phone. - If neither phone nor nameSearch is present, return a 400 telling the assistant to ask for one or the other.
- Resolve the live AutoOps client for the team (decrypts the team's API key from Supabase under the hood).
- Mirror lookup first via
lookupAutoOpsMirrorCustomers({teamId, phone?, nameSearch?}). Three behaviors:- If
phoneis present: queriesautoops_customersfiltered byteam_id+normalized_phone(digits-only equality). Sorted byautoops_updated_atdesc, top 5. Returns immediately, even if empty. - If
nameSearchis present (no phone): splits the input on whitespace, takes up to 3 terms, runs anilike '%term%'query onfirst_nameANDlast_namefor each term, unions, dedupes byautoops_customer_id, sorts byautoops_updated_atdesc, top 5. - If neither: returns
[].
- If
- If the mirror returned hits, return them. Mark
source: 'autoops_mirror'. Don't fall through to live. - If the mirror is empty, fall through to live AutoOps:
client.lookupCustomer({ phone?, nameSearch? }). The live API's name-search semantics are not the same as the mirror'silike(see A.3). - If live returns 403 (
isAutoOpsForbidden), the team's API key isn't authorized for the lookup endpoint — return a friendly "lookup not enabled" message. - Otherwise return live result.
success: true,sourcenot set (implicit live).
A.2 Where to watch it
| Surface | Filter / query | What you see |
|---|---|---|
| Vapi transcript | dashboard.vapi.ai → call detail → transcript timeline | Tool-call entry with the literal arguments the LLM sent ({phoneNumber: "...", nameSearch: "..."}); tool-result entry with the JSON the handler returned |
| Datadog logs | service:avoca-next-prod resource_name:"POST /api/vapi/tools/dispatch" @callId:<callId> | Dispatch routing line + handler logs + Supabase query timings + outbound HTTP to AutoOps |
| Datadog APM | trace tree under the dispatch span | Child spans: pg for the mirror queries; http for the live AutoOps fallback; full latency breakdown |
| Supabase Studio | select * from autoops_customers where team_id = 2678 and (normalized_phone = '<digits>' or first_name ilike '%<term>%' or last_name ilike '%<term>%') order by autoops_updated_at desc limit 5; | Reproduce exactly what the mirror lookup ran |
| Mirror freshness | select max(synced_at), max(autoops_updated_at) from autoops_customers where team_id = 2678; | Confirms mirror sync is healthy (should be within 5 min) |
| AutoOps direct | curl -H "Authorization: ApiKey $AUTOOPS_API_KEY" "https://api.autoops.com/v1/customers?phone=<digits>" and ?nameSearch=<terms> | What the live API would have returned. If live 200s with a match but mirror is empty, the mirror is stale. |
| Hamming | (if mode (b) wired — see A.4) | Reproduces the LLM's argument shaping without dialing |
A.3 Common failure modes
A.3.1 Mirror stale: customer added/updated within the last 5 min
- Tell: mirror returns
[]for both phone and nameSearch; live returns the customer. - Confirm: check
synced_atonautoops_customersfor any row in team 2678. If the most recent row is more than ~6 min old, sync is paused or backed up. The mirror sync orchestrator runs every 5 min for team 2678 (re-enabled via PR #9980). - Action: wait one cycle, or fall back to live (handler already does this when mirror returns empty AND the call has a name to search by).
A.3.2 Name-search miss: customer name in DB doesn't match what the LLM passed
- Tell: caller says "Avoca Test"; LLM tool-call args show
nameSearch: "Avoca Test"; mirror returns[]; live returns[]or a different customer. - Likely root cause: the customer record's name doesn't contain both tokens. The mirror's
ilike '%Avoca%'ANDilike '%Test%'query unions matches across both terms — but if the customer isfirstName: "Avoca", lastName: "TestCustomer", the term "Test" still matcheslast_name ilike '%Test%'and the customer SHOULD appear. If they don't appear, the customer is missing or the names are stored differently than spoken. - Confirm: run the SQL query in A.2 directly. If it returns empty, the customer either isn't synced yet (A.3.1) or has a fundamentally different name in AutoOps than what the caller said.
- Action: check AutoOps live API directly with the same nameSearch. If live also returns empty, the customer's name in AutoOps differs. Either ask AutoOps to update, or train the assistant prompt to ask for phone earlier.
A.3.3 Phone normalization mismatch
- Tell: caller's phone is
(555) 123-4567; mirror returns[]; live returns a match. - Mirror:
normalized_phonecolumn stores digits-only.toAutoOpsPhone()strips non-digits before query. So5551234567should matchnormalized_phone = '5551234567'. If the mirror has the same number stored as15551234567(with country code), digit-only comparison fails — the mirror sync's normalization differs fromtoAutoOpsPhone. - Confirm: run
select normalized_phone from autoops_customers where team_id = 2678 and normalized_phone like '%5551234567%';— if the row exists with country code prefix, this is the bug. - Action: raise to AutoOps team as a normalization-consistency issue. Short-term, the live fallback resolves it.
A.3.4 Live name-search stricter than mirror's ilike
- Tell: mirror returns
[]for nameSearch (empty mirror or no fuzzy match); live returns[]for the same nameSearch; phone path works. - Likely root cause: AutoOps's live
?nameSearch=is exact-match or prefix-match, not substring. "Avoca Test" doesn't match a customer named "Avoca Test Account". The mirror'silike '%term%'would catch this but isn't searching that way (or the customer isn't in the mirror). - Confirm: direct curl with several name variants. If
?nameSearch=Avocareturns the customer but?nameSearch=Avoca Testdoesn't, AutoOps live is matching the full string against a single field, not splitting tokens. - Action: enrich
lookupAutoOpsMirrorCustomersto be the canonical name-matcher; the live fallback only carries weight for phone.
A.3.5 AutoOps API key not enabled for lookup
- Tell: handler returns
success: false, message: "AutoOps customer lookup is not enabled for this API key yet...". Assistant says it can't help with existing-customer flows. - Confirm: Datadog HTTP span shows AutoOps responded 403;
isAutoOpsForbidden(error)matched the response body shape. - Action: ping Lucy / AutoOps support to enable the customer-lookup scope on the EAS team's API key.
A.4 Hamming on-demand sim recipe
To be filled during the first joint walkthrough
The Hamming integration is documented at a high level in dev-env-access.md and there's a CI workflow at .github/workflows/hamming-checks.yml that runs against apps/web/lib/clients/avoca/integrations/hamming*.ts. The exact on-demand command (mode (b)) is to be confirmed once Sandy and the agent run the first sim together. Expected shape:
bash
# From the avoca-next worktree:
pnpm --filter web hamming:sim \
--assistant <EAS pilot assistant id> \
--scenario "customer-lookup-by-name" \
--customer-name "Avoca Test"The sim should fire autoOpsLookupCustomer with nameSearch: "Avoca Test" and report whether the assistant got a hit, fell through to live, or returned no match. If the sim succeeds but the real call fails, the divergence is between Hamming's argument shaping and the production assistant's prompt — investigate the Vapi assistant's tool description.
Scenario B — Reschedule post-call (DB updates but AutoOps not reached)
The reference symptom: caller asks to reschedule a job to a different date. Call ends, post-call workflow runs, the calls row in Supabase shows rescheduling: true and data_store.reschedulingInfo populated. But the AutoOps job's currentStartAt is unchanged. A direct curl to AutoOps's reschedule endpoint with the same jobId + scheduledAt works.
B.1 What happens
In-call (intent capture):
- The Vapi assistant invokes the in-call rescheduling tool, which doesn't itself call AutoOps. It captures the customer's intent in the transcript and proceeds.
- Handler:
lib/vapi/tools/handlers/handleRescheduling.ts.
End-of-call (post-call workflow trigger):
- Vapi sends an end-of-call report webhook.
- The end-of-call handler computes the post-call workflow events and emits an Inngest event.
- Inngest function
EndOfCallReportInngestFunction(lib/workflow/post-call/EndOfCallReportInngestFunction.ts) consumes the event, runs the workflow triager.
Workflow stage triage:
lib/workflow/stages/run/run-triage-home-service.tsswitches onrow.type. For'rescheduling', it calls eitherrunFourSeasonsRescheduling(whentypedConfig.crm === 'FOUR_SEASONS') orrunReschedulingWorkflow.
Reschedule stage (the failure point):
lib/workflow/stages/rescheduling/run-rescheduling.tsis the runner.- It does:
- Load call/text-conversation context (assistantId, callerId, possibly
conversationStJobId). - Extract
reschedulingInfoviagetReschedulingInfo(LLM-driven extraction: providedPhoneNumber, names, requested time, appointmentId). - Resolve the appointment to reschedule via
getLLMFormattedAppointmentsWithConfig+getResolvedAppointmentId. - Write
reschedulingInfotocallstable viaupdateTablesWithReschedulingInfo. (This is the DB write Sandy sees.) - Call
rescheduleSTAppointmentto actually reschedule. This is hard-coded to ServiceTitan. There is no CRM dispatch here. (source,run-rescheduling.ts:285-298) - Write the result to
callsviaupdateTablesWithReschedulingResult.
- Load call/text-conversation context (assistantId, callerId, possibly
The AutoOps function exists but is unwired:
- WT1 (PR #9877, branch
feat/autoops-reschedule-postcall) introduceslib/workflow/stages/rescheduling/rescheduling-autoops.ts— exportsrescheduleAutoOpsAppointment(args, ctx). - WT1 also adds tests against that function.
- WT1 does NOT modify
run-rescheduling.tsto dispatch to it. The function is exported, tested, and dead code in the production path. (Verified viagit diff main..HEAD apps/web/lib/workflow/stages/rescheduling/run-rescheduling.tsin the cancel-polish worktree — only changes areclassifyAndSetTeamCallReasoninsertion and avoiceAssistantIdlookup. Noif (crm === 'AUTOOPS')branch.)
B.2 Where to watch it
| Surface | Filter / query | What you see |
|---|---|---|
| Vapi transcript | dashboard.vapi.ai → call detail | The customer's reschedule intent in the conversation; whether handleRescheduling was invoked in-call |
| End-of-call payload | Datadog: service:avoca-next-prod resource_name:*end-of-call* @vapiCallId:<id> | Confirms Vapi sent the end-of-call report and our route received it |
| Inngest dashboard | function id EndOfCallReportInngestFunction, filter event payload by vapiCallId | The run row; click in for the step graph and step input/output |
| Inngest step output | Run page → runReschedulingWorkflow step → output panel | What the rescheduling stage returned: reschedulingInfo, reschedulingResult |
| Datadog rescheduling logs | service:avoca-next-prod @logger.name:runReschedulingWorkflow @callId:<callId> | Each log emitted by the stage runner |
| Datadog AutoOps egress | service:avoca-next-prod @http.url:*api.autoops.com* for the call window | If absent, AutoOps was never called |
Supabase calls | select id, rescheduling, call_reason, data_store->'reschedulingInfo' as info, data_store->'reschedulingResult' as result from calls where id = '<callId>'; | DB writes from steps 4 + 6. If info is set but result is null or has empty fields, the stage ran but the external mutation didn't land. |
| AutoOps direct GET | curl -H "Authorization: ApiKey $AUTOOPS_API_KEY" "https://api.autoops.com/v1/jobs/<jobId>" | Source of truth for currentStartAt, updatedAt. Compare with the requested time. |
| Hamming sim | (if mode (b) wired — see B.5) | Reproduces the post-call extraction without dialing |
B.3 The bug, ranked by hypothesis
| # | Hypothesis | Verifiable by | Likelihood |
|---|---|---|---|
| 1 | Reschedule stage has no AutoOps dispatch. runReschedulingWorkflow always calls rescheduleSTAppointment; for AutoOps teams, rescheduleSTAppointment either no-ops or errors silently, so DB writes happen but AutoOps is never touched. | Read run-rescheduling.ts:285-298 on main and on the WT1 branch. Both call rescheduleSTAppointment unconditionally. Confirmed. | High — this is the smoking gun. |
| 2 | rescheduleSTAppointment for an AutoOps team errors and is swallowed by the outer try in EndOfCallReportInngestFunction, leaving the call row in an inconsistent state. | Datadog: filter @logger.name:runReschedulingWorkflow @level:error. Look for ServiceTitan-specific errors during the run window. | High — supports H1's silent-no-op story |
| 3 | The stage attempts a different mechanism (FOUR_SEASONS branch) that's wrong for AutoOps. | Check typedConfig.crm for the EAS team. If it's 'FOUR_SEASONS' or anything other than 'SERVICE_TITAN', the wrong branch fires. | Medium — depends on team config |
| 4 | The end-of-call event never enqueued a 'rescheduling' row at all. The row that ran might have been 'process' only. | Inngest run step list: is there a runReschedulingWorkflow step? If absent, post-call triage didn't classify the call as a reschedule. | Medium — would make B.1 step 5 not fire |
| 5 | In-call handleRescheduling did fire and "rescheduled" something via an in-call path that bypassed the post-call workflow entirely. AutoOps was never reached because no path led to it. | Vapi transcript shows handleRescheduling tool call. Datadog: did any AutoOps egress happen during the call? | Low — but a sanity check |
| 6 | All the above are red herrings; AutoOps was actually called, succeeded with 200, but the mirror's delta sync (autoops_jobs table) didn't refresh. The updatedAt-not-bumping bug Sandy already found means delta sync silently misses reschedules. | Direct GET on the AutoOps job. If currentStartAt IS updated but the mirror has the old value, this is what happened. | Already-known issue (handoff: "AutoOps updatedAt not bumping on reschedule"). Probably not the primary cause for THIS symptom because the workflow's reschedule call would have happened during the run window — and there's no Datadog evidence it did. |
Diagnostic order: confirm H1/H2 first by reading the run record on Inngest and the Datadog egress span. The fix isn't a Datadog dive; it's wiring rescheduleAutoOpsAppointment into run-rescheduling.ts behind an if (typedConfig.crm === 'AUTOOPS') branch (or whatever the AutoOps CRM enum value is).
B.4 Common failure modes (beyond the bug)
| Mode | Tell | Confirm | Action |
|---|---|---|---|
| Stage didn't run at all | No runReschedulingWorkflow step in the Inngest run | Inngest dashboard run-step graph | Triage logic in run-triage-home-service.ts didn't classify the call as a reschedule. Check the row source. |
| Stage ran, AutoOps call body malformed | Datadog HTTP span 400 | DD trace for the call | Check rescheduleAutoOpsAppointment's body shape against AutoOps's API contract |
| AutoOps responded 200 but mirror stale | AutoOps direct GET shows new currentStartAt; autoops_jobs table shows old | Mirror sync orchestrator log + last_updated_at_gte cursor state | Known: updatedAt-not-bumping bug. Manual sync until upstream fix. |
| Idempotency-Key collision rejecting retries | AutoOps 409; Inngest step retried | DD trace shows 409 followed by step retry | Idempotency-Key must include retry attempt or be regenerated. PR #9877's keying is ${callId}-reschedule-${jobId} — stable across retries by design. |
| Stage's appointment resolution returned null | reschedulingResult is null in calls.data_store | DB query in B.2 | LLM didn't extract a usable appointmentId/jobId. Re-extract from transcript. |
B.5 Hamming on-demand sim recipe
To be filled during the first joint walkthrough
Hamming's sim path can replay a saved transcript through the post-call workflow without redialing — this is exactly what we need to validate B.3 hypotheses without burning a real call. Expected shape:
bash
# From the avoca-next worktree:
pnpm --filter web hamming:sim \
--assistant <EAS pilot assistant id> \
--transcript fixtures/reschedule-failure-2026-05-06.txt \
--post-callThis should fire the post-call workflow against the same input the failed real call produced. Compare Inngest run + Datadog egress to the real call's run.
B.6 Replay recipe (without redialing)
To be filled during the first joint walkthrough
Inngest supports re-running a function for a specific event. From the dashboard:
- Navigate to the failed run.
- Click Replay on the event.
- Watch the new run's step graph.
Useful when the fix is in code (e.g., we wired rescheduleAutoOpsAppointment into run-rescheduling.ts and want to verify against the original event payload). Replay reuses the original event, so any Date.now() / clock-sensitive logic gets the original timestamp — note this if the bug interacts with time.
CLI alternative (verify which is current syntax):
bash
# From avoca-next worktree (Inngest CLI installed via pnpm inngest):
pnpm inngest events replay --event-id <eventId>Scripts
Quick-find helpers in scripts/observe/ at the toolkit root. v1 are link-printers (and one read-only API call): they tell you the deep link to paste, with the right query already URL-encoded. v2 will add --fetch for live data, summaries, and replay actions. Read-only by default; any mutation will require --write.
Run from the toolkit root
All four scripts live in the toolkit and are invoked from the toolkit root: ./scripts/observe/<name>.sh .... They print URLs and SQL — they do not touch credentials except autoops-job.sh, which reads AUTOOPS_API_KEY from the environment.
scripts/observe/last-call.sh
Purpose. When you say "what just happened on my last call?", this is the entry point. Prints deep links to: Vapi call list, Datadog dispatch + end-of-call queries, Inngest run list, and a Supabase SQL snippet to find the most recent calls for the EAS team.
Run.
bash
./scripts/observe/last-call.shSample output. Prints six surfaces with pre-filtered URLs. Once you have the call's id from Supabase or Vapi, feed it into trace.sh and inngest-run.sh.
v2 follow-ups. Add --fetch to query Supabase directly and print the latest 5 callIds inline; add --vapi to print live links from Vapi's API.
scripts/observe/trace.sh <callId>
Purpose. Given a callId (Avoca-internal UUID or Vapi call id), print Datadog trace + log URLs pre-filtered to that call, including AutoOps-egress and rescheduling-stage filters.
Run.
bash
./scripts/observe/trace.sh <callId>Sample output. Six URLs: APM trace by internal callId, APM trace by vapiCallId, logs by callId, logs filtered to AutoOps egress, logs filtered to rescheduling stage. Vapi dashboard URL also included (works on the vapi_call_id specifically).
v2 follow-ups. Add --fetch to actually pull and summarize the trace tree; flag missing AutoOps egress spans as "stage didn't reach AutoOps".
scripts/observe/inngest-run.sh <callId>
Purpose. Find the Inngest post-call workflow runs for a callId and tell you where to click for step graphs and replay.
Run.
bash
./scripts/observe/inngest-run.sh <callId>Sample output. Inngest dashboard URL with filter recipe; CLI alternative for replay.
v2 follow-ups. Use the Inngest API (or pnpm inngest events list) to inline the matching event ids and run statuses.
scripts/observe/autoops-job.sh <jobId>
Purpose. Source-of-truth probe: what does AutoOps actually say about a job? Resolves the question "did the reschedule land or not?".
Run.
bash
# Requires AUTOOPS_API_KEY (per dev-env-access.md):
export AUTOOPS_API_KEY="..."
./scripts/observe/autoops-job.sh <jobId> # condensed
./scripts/observe/autoops-job.sh <jobId> --raw # full JSONSample output (condensed mode).
json
{
"id": "job_a1f4c3e8929c423a846c1627017513e3",
"status": "scheduled",
"scheduledStartAt": "2026-07-20T14:00:00.000Z",
"scheduledEndAt": "2026-07-20T16:00:00.000Z",
"currentStartAt": "2026-07-27T19:00:00.000Z",
"currentEndAt": "2026-07-27T21:00:00.000Z",
"customer": { "id": "...", "firstName": "Avoca", "lastName": "Test" },
"updatedAt": "2026-05-04T20:05:08.703Z",
"createdAt": "2026-05-04T20:05:08.703Z"
}The currentStartAt vs scheduledStartAt divergence here is the canonical "reschedule landed" signature. The handoff documents an upstream AutoOps bug where updatedAt does NOT bump on reschedule even though currentStartAt does — relevant when judging whether mirror sync will catch it.
v2 follow-ups. Add --diff <expected-iso> to flag drift, --mirror-compare to print the mirror's stored row alongside, and a separate autoops-reschedule.sh (write-mode, gated by --confirm) for direct mutations during testing.
Live walkthrough log
Each diagnostic walkthrough Sandy and the agent run together gets a dated entry below. The format: date, scenario, what we actually observed at each surface, what we learned that changed the playbook. This is institutional memory — by month two there should be enough entries that "I've seen this before" becomes a usable reflex.
2026-05-07 — Pending: first walkthrough
Planned: dial the EAS pilot, ask to reschedule, walk Scenario B's table top-to-bottom on a real call. Confirm or refute B.3 H1 (AutoOps dispatch missing in run-rescheduling.ts). Capture exact Datadog filter strings that work for the live service name. Capture Vapi call URL pattern. Capture Inngest function id verbatim.
Open questions
- Datadog service name. This page assumes
avoca-next-prodfor prod andavoca-next-devfor dev. Confirm during the first walkthrough — if the actualservicetag differs, update every Datadog URL across the page. - Vapi call id vs. Avoca-internal call id. Two distinct identifiers; the playbook tries to handle both. Confirm during walkthrough which one the in-house logging primarily emits, and which the dashboards key off.
- Inngest function id for the post-call workflow. Code shows
END_OF_CALL_REPORT_INNGEST_NAMEconstant — the actual id string may differ. Confirm in the Inngest dashboard. - AutoOps CRM enum value in
typedConfig.crm. Is it'AUTOOPS','AUTO_OPS', or something else? Affects B.3 H3 (wrong-branch hypothesis). Resolve by readingtypedConfig.crmfor the EAS team. - Hamming on-demand sim invocation. Mode (b) recipe is stubbed in A.4 / B.5. Resolve by running a sim against the EAS pilot assistant and capturing the exact pnpm command.
- In-call rescheduling tool's relationship to post-call workflow. Does
handleReschedulingever directly mutate AutoOps in-call, or is it strictly intent-capture for the post-call stage? Affects B.3 H5. - Fix sequencing for the actual bug. Once H1 is confirmed, the fix is: add CRM dispatch in
run-rescheduling.tsand route AutoOps teams torescheduleAutoOpsAppointment. This belongs in a follow-up PR after #9877 lands. Owner + branch TBD.