Appearance
Post-call dispatch chain — AutoOps reschedule path
Why this doc exists. Before drafting any fix to the post-call workflow, walk this chain top-to-bottom. We've been bitten twice by drafting at the wrong layer (yesterday: wrong runner; today: wrong vertical). The dispatch is vertical → CRM → action with several places that look like the right place but aren't. This doc is the source of truth for which layer makes which decision.
How to read this doc
Each layer (L0-L7) describes one stage of dispatch. Per layer:
- What it does — one paragraph.
- Inputs that drive its decision — what it switches on.
- Outputs / hand-off — what it passes downstream.
- Source citation — exact file:line.
- Failure modes — how a bug at this layer would manifest.
The composite diagram at the bottom shows all layers stacked.
L0 — Vapi end-of-call webhook arrives → enqueue
What it does. Vapi finishes the call and POSTs an end-of-call report to a per-team webhook route on avoca-next. The route handles team-specific concerns (auth, transformation, side-effects), then calls scheduleEndOfCallReport(...) which:
- Inserts a row into Supabase
responder_workflow_runswithstatus: 'SCHEDULED'. This is the durable queue. - In
NODE_ENV === 'development': immediately flips the row toQUEUEDand callsinngest.send({ name: END_OF_CALL_REPORT_INNGEST_NAME, ... }). - In production/staging: does NOT call
inngest.senddirectly. The separate cron Inngest functionEndOfCallReportCronInngestFunctionpolls SCHEDULED rows on a schedule, marks them QUEUED, and dispatches the Inngest event.
So the queue intermediary matters: in prod, end-of-call → DB row → cron → Inngest event. In dev, end-of-call → DB row + immediate dispatch.
Input: Vapi's HTTP POST to the team's webhook route. The body is a Vapi.ServerMessage (end-of-call-report variant).
Output (hand-off to L1): eventually, an inngest.send({ name: 'responder.end-of-call-report', data: EndOfCallReportInngestData }). data.message is the PostCallReport; data.teamId resolves the team; data.workflowRunId is the Supabase row id that tracks lifecycle.
Source. Per-team Vapi webhook routes live under pages/api/responder/<team-slug>/.../webhook.ts (or shared routes under pages/api/responder/common/). For the EAS pilot, the exact route is TBD — confirm during live walkthrough. The shared scheduling helper is lib/workflow/post-call/end-of-call-report.ts:scheduleEndOfCallReport() (the dev-mode inngest.send is at line 237; the dropped-responder side path at line 170 is unrelated to the post-call workflow trigger). The cron consumer is EndOfCallReportCronInngestFunction.ts.
Failure modes.
- Webhook 4xx/5xx: Vapi dashboard shows failed delivery; no DB row inserted; workflow never runs.
- DB row inserted but the prod cron is paused / errored: rows pile up at
status: SCHEDULED. Visible via direct query ofresponder_workflow_runs. - Cron dispatches but the row's
last_inngest_run_iddoesn't match (race / duplicate dispatch): L1'svalidate-workflow-runstep skips on stale invocations. teamIdnot resolved here gets resolved in L1'sprepare-workflowstep instead (viagetTeamIdFromVapiMessage) — a payload missing botheventTeamIdand a Vapi assistant id throws there.
L1 — Inngest function consumes the event
What it does. EndOfCallReportInngestFunction is registered in app/api/inngest/route.ts and runs on the responder.end-of-call-report event. The function is structured as a sequence of Inngest step.run blocks so each phase is independently retryable on failure (the function declares retries: 0 at the top level and relies on Inngest's per-step retry semantics + a sophisticated onFailure handler that distinguishes "workflow produced a result but a side-effect threw" from "core workflow itself failed"). The phases are:
- Step
validate-workflow-run— loads theresponder_workflow_runsrow, atomically claims it (claimResponderWorkflowRun) so duplicate dispatches no-op. - Step
prepare-workflow— callsprepareWorkflowStep(defined later in the same file). ResolvesteamIdif missing; callsprepareCallData; sets the call'sprocessing_statusto'processing'; loads the team'sTypedWebhookConfig; instantiates the triager viaWorkflowRunTriagerFactory.create(...); runs the triager'sprepare()to determine outcomes and persist them asoutcome_resultsrows. Returns a fully-serializablePrepareWorkflowResult(no class instances — Inngest serializes step output as JSON). - Step
execute-outcome-{i}-{type}per outcome row — callsexecuteOutcomeStep, which rebuilds the triager from the serialized prepared data viacreateTriagerFromPrepared, then callstriager.runOutcome(row). The triager'srunOutcomecalls the per-verticalexecuteOutcome(row)which is L4. - Step
finalize-workflow— callsfinalizeWorkflowStep, which rebuilds the triager and callstriager.finalize(...)(correctsis_bookable, marks rows completed). - Subsequent steps —
send-inbound-call-completedevent, new-customer-signup, dropped-responder lead, event-webhooks delivery, etc.
The key point: the triager class methods (prepare, runOutcome, finalize) are the same primitives used everywhere else (e.g., the runWorkflow helper in run-triage.ts for ARS / one-off contexts), but the production end-of-call path does NOT call runWorkflow. It inlines the phases as Inngest steps so each phase is individually replayable.
Input. The Inngest event payload from L0.
Output (hand-off to L2): the prepare-workflow step calls WorkflowRunTriagerFactory.create(...) which is L2.
Source. Function definition: EndOfCallReportInngestFunction.ts:130. Step orchestration body: lines 217-360 (validate → prepare → loop execute-outcome → finalize → post-processing). Step helper definitions: prepareWorkflowStep at line 593, executeOutcomeStep at line 897, finalizeWorkflowStep at line 943, createTriagerFromPrepared at line 870. Event constant: END_OF_CALL_REPORT_INNGEST_NAME = 'responder.end-of-call-report' (lib/inngest/events/EndOfCallReportInngestEvent.ts:3).
Failure modes.
- Function paused / rate-limited in Inngest: event enqueued but never processed;
responder_workflow_runsrow stays QUEUED. getTypedConfigreturns wrong vertical/crm: the entire downstream chain misroutes. Theprepare-workflowstep's logs reflect the resolved vertical; visible in DD by filtering on the call'scallIdorworkflowRunId.- A step throws after the workflow already produced a usable result:
onFailurehandler detects this (call?.result != null) and finalizes asprocessed, marking only theresponder_workflow_runsrow as FAILED. Avoids spurious Slack alerts. - All retries exhausted with no result:
onFailuremarks the call'sprocessing_status: 'failed'and pages oncall viasendResponderSlackAlert.
L2 — Vertical dispatch: choose the triager
What it does. WorkflowRunTriagerFactory.create switches on typedConfig.vertical and instantiates one of the per-vertical triagers.
Input: typedConfig.vertical — one of AUTO_SERVICE, WINDOW, FLOORING, JUNK_REMOVAL, ROOFING, or default → HOME_SERVICES.
Output (hand-off to L3): an instance of the per-vertical WorkflowRunTriager class.
Source. lib/workflow/stages/run/run-triage.ts:15-37.
For the EAS pilot: vertical = 'AUTO_SERVICE' → AutoServiceWorkflowRunTriager.
Failure modes.
- Wrong
verticalconfig for the team → wrong triager → entirely wrong dispatch chain. This was yesterday's diagnostic miss. - New vertical added without updating this factory: falls through to
HOME_SERVICESdefault which may not handle the team's CRM at all.
Why "vertical" matters
The vertical determines what kinds of outcomes a call can have. AUTO_SERVICE knows about booking / rescheduling / cancellation / message. ROOFING knows about its own outcome types. HOME_SERVICES is the broadest with rescheduling, cancellation, ETA, booking, etc. The vertical also determines which CRM enums are valid downstream.
L3 — Outcome determination: classify the call + persist as outcome_results rows
What it does. The base class WorkflowRunTriager.prepare() is invoked from L1's prepareWorkflowStep. It:
- Checks the
outcome_resultstable for existing rows for thiscallId— if present, returns them (idempotency: a retry of L1 reuses the same outcomes, doesn't reclassify). - Otherwise calls the abstract
determineWorkflowOutcomes()(implemented per-vertical), validates the result, inserts the rows into Supabase asoutcome_resultswith statuspending, and returns them.
For AUTO_SERVICE, determineWorkflowOutcomes() calls determineAutoServiceWorkflowOutcomes — an LLM call (gpt-4.1, temp 0, seed 123) that returns an array of typed outcomes plus guidance.
Input: the call transcript + team-level custom guidance + (on retry) any persisted rows.
Output (hand-off to L4): an array of OutcomeRows. For AUTO_SERVICE, each row's type is one of AUTO_SERVICE_OUTCOME_TYPES = ['booking', 'rescheduling', 'cancellation', 'message']. Rows transition through pending → executed → completed as L4-L7 progress.
Source. Base class prepare(): WorkflowRunTriager.ts:108-141. AUTO_SERVICE classifier: run-type-auto-service.ts.
For the EAS pilot reschedule case: the LLM correctly classifies a reschedule call as type: 'rescheduling' based on the prompt's clear definition ("existing appointment was moved to a different date/time").
Failure modes.
- LLM mis-classification: a rescheduling call labelled
message→ no action attempted. - LLM hallucinated outcome types not in the enum: filtered out silently.
- Custom guidance overrides skewing classification.
L4 — CRM dispatch: route the outcome to the runner
What it does. Each triager's executeOutcome(row) runs once per outcome row and dispatches to the runner that knows how to fulfill that outcome for this team's CRM. For AutoServiceWorkflowRunTriager.executeOutcome, the only supported CRM under AUTO_SERVICE is AUTO_OPS, and it routes ALL outcome rows to runAutoOpsBookingWorkflow regardless of row.type.
Input: the outcome row from L3, plus typedConfig.crm.
Output (hand-off to L5): invocation of runAutoOpsBookingWorkflow(callCtx, context).
Source. run-triage-auto-service.ts:59-93.
For the EAS pilot: crm === 'AUTO_OPS' → runAutoOpsBookingWorkflow. Note: row.type is NOT used here; classification is recovered inside the runner via a separate LLM extraction.
Failure modes.
- Wrong CRM config: returns the unsupported-CRM error result without touching AutoOps.
- Outcome rows are routed without consulting
row.type: any per-action behavior must live inside L5.
Compare with HOME_SERVICES
HOME_SERVICES's executeOutcome DOES switch on row.type (case 'rescheduling' → runReschedulingWorkflow, case 'cancellation' → runCancellationWorkflow, etc.). AUTO_SERVICE collapses to one runner. This shape difference is why fixes designed against HOME_SERVICES don't apply to AUTO_SERVICE.
L5 — Action dispatch: branch on callReason inside the runner
What it does. runAutoOpsBookingWorkflow loads the AutoOps client, runs LLM extraction (runAutoServiceExtraction — separate from L3's classification, this one extracts detailed fields), then branches on extraction.extractedData.avoca.callReason:
'Booking' && appointmentBooked→runBooking(...)→ calls AutoOpsPOST /book'Rescheduling'→ stub: setsautoOpsAction='reschedule', logs "manual follow-up", does NOT call AutoOps'Cancellation'→ stub: setsautoOpsAction='cancel', logs "manual follow-up", does NOT call AutoOps
Input: the call context + AutoOps config + extraction result.
Output (hand-off to L6): invocation of a leaf function (currently only runBooking is wired; Rescheduling and Cancellation skip L6).
Source. run-booking-autoops.ts:118-143.
For the EAS pilot reschedule case: callReason === 'Rescheduling' matches the stub branch → no L6 leaf invocation → AutoOps's reschedule endpoint never called.
Failure modes.
- Stub branches (the current bug). The branch is matched but no leaf is called. Symptom: DB updated by L7 anyway, AutoOps unchanged.
- LLM mis-extraction:
callReasondoesn't match any branch → the runner falls through withautoOpsAction = null. - Booking branch's
appointmentBookedflag is false: booking attempt skipped (intentional gating).
This is the layer the fix targets
The PR replaces the two stub branches with calls to rescheduleAutoOpsAppointment and cancelAutoOpsAppointment (the leaves from #9877 / #10012). See post-call-runner-crm-dispatch for the patch shape.
L6 — Leaf: actual external API call
What it does. Each leaf is a self-contained function that performs one external API mutation. For the post-fix world: rescheduleAutoOpsAppointment extracts jobId from transcript via getExistingAutoOpsJobIdFromTranscript, validates it via client.getJob, then calls client.reschedule(jobId, { scheduledAt }) with a deterministic Idempotency-Key. Maps AutoOps response into a structured success or graceful-failure result.
Input: { teamId, transcript, scheduledAt, callId, client }.
Output (returned to L5): RescheduleAutoOpsResult — either {success:true, jobId, scheduledAt} or {success:false, reason, status?, message}.
Source. lib/workflow/stages/rescheduling/rescheduling-autoops.ts (introduced in PR #9877; lives only in the cancel-polish worktree until merge).
Failure modes.
- Network / 5xx / auth errors are re-thrown so Inngest can retry the L1 step.
- 4xx-business errors (404/409/422) returned as graceful failure (no throw) so the workflow records the failure without crashing.
Idempotency-Keyis deterministic on${callId}-reschedule-${jobId}so retries dedupe server-side.
L7 — Side-effects: DB writes + email + AutoOps state
What it does. Regardless of whether L6 ran, L5's outer code always:
- Calls
updateAutoServiceCallRecord(ctx, extraction, autoOpsJobId, log)to persist call data. - Calls
sendAutoServiceEmail(...)to email the shop with success or fallback notice. - Returns a structured
AutoOpsBookingResultto L4 → L3 → L1 → Inngest.
Source. run-booking-autoops.ts:162-220.
Failure modes.
- DB update wrapped in try/catch with
console.error— silent failures show up in Datadog but not in the call row. - Email send wrapped similarly.
Composite — all layers stacked
Where the dispatch can break — checklist by layer
| Layer | Failure mode | How to detect |
|---|---|---|
| L0 | Webhook 4xx/5xx | Vapi delivery log; no responder_workflow_runs row inserted |
| L0 | DB row stuck at SCHEDULED | Direct query of responder_workflow_runs for old SCHEDULED rows; check EndOfCallReportCronInngestFunction health |
| L0 | Wrong teamId resolved or missing | L1's prepare-workflow step throws "Team or assistant ID is required" — visible in Inngest run as a step failure |
| L1 | Function paused/rate-limited | Inngest dashboard run status |
| L1 | Wrong vertical/crm in typedConfig | prepare-workflow step's emitted logs (DD filter on callId or workflowRunId); compare to expected team config |
| L2 | New vertical not registered → falls through to HOME_SERVICES | Compare team's vertical config vs factory cases |
| L3 | LLM mis-classification | Inngest run step output: outcomes array |
| L4 | Unsupported CRM under vertical | executeOutcome returns error result |
| L4 | row.type ignored when it shouldn't be (AUTO_SERVICE quirk) | Check whether the runner uses row.type or recovers via separate extraction |
| L5 | Stub branch (current bug) | Inngest step output shows autoOpsAction set but autoOpsResult.success === false and no AutoOps egress in DD |
| L5 | LLM extraction callReason doesn't match a branch | DD log "AutoOps action selection" with empty/unmatched callReason |
| L6 | Network/5xx (re-thrown) | Inngest step retried/failed |
| L6 | Business 4xx (graceful) | Result {success:false, reason} recorded in data_store |
| L7 | Silent DB error | DD log only; calls row not updated |
| L7 | Email send failure | Email service log; no notification to shop |
Scope limits of this v1
This v1 covers ONLY the AutoOps reschedule path through the AUTO_SERVICE vertical. Future v2 work:
- HOME_SERVICES vertical — different L4 (CRM dispatch lives there) and L5 (per-action runners). The L5/L6 split for ServiceTitan looks like
runReschedulingWorkflow → rescheduleSTAppointment. Four Seasons has its own runner branch at L4. HCP has CRM-specific branches insiderescheduling-st.tsitself. - Other verticals — WINDOW, FLOORING, JUNK_REMOVAL, ROOFING each have their own triager + outcome-determination logic. Map them.
- Other CRMs under AUTO_SERVICE — currently only AUTO_OPS. If a second CRM ever gets added (Shopmonkey, Tekmetric, etc.), L4 will need to learn that CRM, and L5 will need a non-AutoOps runner.
Maintenance
When new code lands at any layer (new vertical, new CRM, new action), update the corresponding section + any failure-mode rows. Treat this doc as the "definition of done" check: a fix is reviewable only if its layer is explicitly identified here.
This doc is the prerequisite reading for any Solutions doc targeting post-call workflow.