Skip to content

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.

How to use this page

  1. Identify the stage where the call diverged from expectation. Use the Stage map below.
  2. 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.
  3. If the failure matches a documented scenario (Customer lookup, Reschedule post-call), follow that scenario's walkthrough.
  4. 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.

#StageWhere it livesWhere to watchCommon failure
1Phone rings; Vapi answersVapi assistant config (Vapi dashboard, not in repo)Vapi dashboard call list, filtered by assistant IDWrong assistant for the inbound number; pick-up but no transcript
2Assistant evaluates system prompt + toolsTool 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 pageLLM picks wrong tool, or no tool when one is needed; usually a prompt-tuning issue, not a code bug
3Vapi POSTs tool-call webhookSingle dispatch route: pages/api/vapi/tools/dispatch.tsDatadog: 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
4Dispatcher 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.tsDatadog log line dispatch tool:<toolName>Tool not in DISPATCH_FUNCTION_NAMES registry → falls through to default case
5Handler executeslib/vapi/tools/handlers/<toolName>.ts (e.g., autoOpsLookupCustomer.ts)Datadog: filter same trace, look at handler-internal spans / log linesHandler-specific — see Scenario A and B
6Handler → Supabase mirror lookuplib/autoops/sync/lookup.ts (AutoOps), lib/service-titan/search-customer.ts (ServiceTitan), per-CRM equivalentsSupabase Studio → autoops_customers (or customers/st_customers); query by team_id + normalized_phone or ilike on namesMirror 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
7Handler → Live API fallbacklib/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
8Vapi result formatted + returnedlib/vapi/with-vapi-tool-call.ts (vapiResult)Vapi transcript shows the tool result entry verbatimResult not JSON-serializable; missing toolCallId → Vapi shows "tool error"
9End-of-call webhook landspages/api/responder/.../end-of-call.ts (per-CRM) → emits an Inngest event named in lib/inngest/events/EndOfCallReportInngestEvent.tsDatadog: service:avoca-next-prod resource_name:*end-of-call* @vapiCallId:<id>; Inngest dashboard: filter by event nameVapi did not send end-of-call event (assistant hung up wrong); event sent but never reached our route (network)
10Inngest receives + enqueues post-call workflowlib/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 graphFunction paused, errored, or rate-limited; check the run's Inngest event payload
11Workflow runs through stage triagelib/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 stepWrong row.type for the call's intent → wrong stage runs; CRM-specific override (typedConfig.crm === 'FOUR_SEASONS') takes a separate branch
12Stage runs and calls external APIPer-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 outputCRM dispatch is missing for AutoOps in the rescheduling runner (Scenario B); call is silently a no-op for non-ServiceTitan teams
13Stage writes results back to SupabasePer-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.reschedulingResultOuter 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.tsautoOpsLookupCustomerHandler(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):

  1. Read args from ctx.message.toolCallList[0].function.arguments. Two named fields: phoneNumber?, nameSearch?.
  2. Compute phone = toAutoOpsPhone(args.phoneNumber ?? ctx.message.customer?.number). Strips non-digits. Falls back to caller ID if LLM didn't pass a phone.
  3. If neither phone nor nameSearch is present, return a 400 telling the assistant to ask for one or the other.
  4. Resolve the live AutoOps client for the team (decrypts the team's API key from Supabase under the hood).
  5. Mirror lookup first via lookupAutoOpsMirrorCustomers({teamId, phone?, nameSearch?}). Three behaviors:
    • If phone is present: queries autoops_customers filtered by team_id + normalized_phone (digits-only equality). Sorted by autoops_updated_at desc, top 5. Returns immediately, even if empty.
    • If nameSearch is present (no phone): splits the input on whitespace, takes up to 3 terms, runs an ilike '%term%' query on first_name AND last_name for each term, unions, dedupes by autoops_customer_id, sorts by autoops_updated_at desc, top 5.
    • If neither: returns [].
  6. If the mirror returned hits, return them. Mark source: 'autoops_mirror'. Don't fall through to live.
  7. 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's ilike (see A.3).
  8. If live returns 403 (isAutoOpsForbidden), the team's API key isn't authorized for the lookup endpoint — return a friendly "lookup not enabled" message.
  9. Otherwise return live result. success: true, source not set (implicit live).

A.2 Where to watch it

SurfaceFilter / queryWhat you see
Vapi transcriptdashboard.vapi.ai → call detail → transcript timelineTool-call entry with the literal arguments the LLM sent ({phoneNumber: "...", nameSearch: "..."}); tool-result entry with the JSON the handler returned
Datadog logsservice: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 APMtrace tree under the dispatch spanChild spans: pg for the mirror queries; http for the live AutoOps fallback; full latency breakdown
Supabase Studioselect * 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 freshnessselect 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 directcurl -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_at on autoops_customers for 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%' AND ilike '%Test%' query unions matches across both terms — but if the customer is firstName: "Avoca", lastName: "TestCustomer", the term "Test" still matches last_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_phone column stores digits-only. toAutoOpsPhone() strips non-digits before query. So 5551234567 should match normalized_phone = '5551234567'. If the mirror has the same number stored as 15551234567 (with country code), digit-only comparison fails — the mirror sync's normalization differs from toAutoOpsPhone.
  • 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's ilike '%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=Avoca returns the customer but ?nameSearch=Avoca Test doesn't, AutoOps live is matching the full string against a single field, not splitting tokens.
  • Action: enrich lookupAutoOpsMirrorCustomers to 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):

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:

Reschedule stage (the failure point):

  • lib/workflow/stages/rescheduling/run-rescheduling.ts is the runner.
  • It does:
    1. Load call/text-conversation context (assistantId, callerId, possibly conversationStJobId).
    2. Extract reschedulingInfo via getReschedulingInfo (LLM-driven extraction: providedPhoneNumber, names, requested time, appointmentId).
    3. Resolve the appointment to reschedule via getLLMFormattedAppointmentsWithConfig + getResolvedAppointmentId.
    4. Write reschedulingInfo to calls table via updateTablesWithReschedulingInfo. (This is the DB write Sandy sees.)
    5. Call rescheduleSTAppointment to actually reschedule. This is hard-coded to ServiceTitan. There is no CRM dispatch here. (source, run-rescheduling.ts:285-298)
    6. Write the result to calls via updateTablesWithReschedulingResult.

The AutoOps function exists but is unwired:

  • WT1 (PR #9877, branch feat/autoops-reschedule-postcall) introduces lib/workflow/stages/rescheduling/rescheduling-autoops.ts — exports rescheduleAutoOpsAppointment(args, ctx).
  • WT1 also adds tests against that function.
  • WT1 does NOT modify run-rescheduling.ts to dispatch to it. The function is exported, tested, and dead code in the production path. (Verified via git diff main..HEAD apps/web/lib/workflow/stages/rescheduling/run-rescheduling.ts in the cancel-polish worktree — only changes are classifyAndSetTeamCallReason insertion and a voiceAssistantId lookup. No if (crm === 'AUTOOPS') branch.)

B.2 Where to watch it

SurfaceFilter / queryWhat you see
Vapi transcriptdashboard.vapi.ai → call detailThe customer's reschedule intent in the conversation; whether handleRescheduling was invoked in-call
End-of-call payloadDatadog: 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 dashboardfunction id EndOfCallReportInngestFunction, filter event payload by vapiCallIdThe run row; click in for the step graph and step input/output
Inngest step outputRun page → runReschedulingWorkflow step → output panelWhat the rescheduling stage returned: reschedulingInfo, reschedulingResult
Datadog rescheduling logsservice:avoca-next-prod @logger.name:runReschedulingWorkflow @callId:<callId>Each log emitted by the stage runner
Datadog AutoOps egressservice:avoca-next-prod @http.url:*api.autoops.com* for the call windowIf absent, AutoOps was never called
Supabase callsselect 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 GETcurl -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

#HypothesisVerifiable byLikelihood
1Reschedule 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.
2rescheduleSTAppointment 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
3The 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
4The 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
5In-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
6All 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)

ModeTellConfirmAction
Stage didn't run at allNo runReschedulingWorkflow step in the Inngest runInngest dashboard run-step graphTriage logic in run-triage-home-service.ts didn't classify the call as a reschedule. Check the row source.
Stage ran, AutoOps call body malformedDatadog HTTP span 400DD trace for the callCheck rescheduleAutoOpsAppointment's body shape against AutoOps's API contract
AutoOps responded 200 but mirror staleAutoOps direct GET shows new currentStartAt; autoops_jobs table shows oldMirror sync orchestrator log + last_updated_at_gte cursor stateKnown: updatedAt-not-bumping bug. Manual sync until upstream fix.
Idempotency-Key collision rejecting retriesAutoOps 409; Inngest step retriedDD trace shows 409 followed by step retryIdempotency-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 nullreschedulingResult is null in calls.data_storeDB query in B.2LLM 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-call

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

  1. Navigate to the failed run.
  2. Click Replay on the event.
  3. 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.sh

Sample 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 JSON

Sample 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-prod for prod and avoca-next-dev for dev. Confirm during the first walkthrough — if the actual service tag 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_NAME constant — 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 reading typedConfig.crm for 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 handleRescheduling ever 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.ts and route AutoOps teams to rescheduleAutoOpsAppointment. This belongs in a follow-up PR after #9877 lands. Owner + branch TBD.