Skip to content

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:

  1. Inserts a row into Supabase responder_workflow_runs with status: 'SCHEDULED'. This is the durable queue.
  2. In NODE_ENV === 'development': immediately flips the row to QUEUED and calls inngest.send({ name: END_OF_CALL_REPORT_INNGEST_NAME, ... }).
  3. In production/staging: does NOT call inngest.send directly. The separate cron Inngest function EndOfCallReportCronInngestFunction polls 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 of responder_workflow_runs.
  • Cron dispatches but the row's last_inngest_run_id doesn't match (race / duplicate dispatch): L1's validate-workflow-run step skips on stale invocations.
  • teamId not resolved here gets resolved in L1's prepare-workflow step instead (via getTeamIdFromVapiMessage) — a payload missing both eventTeamId and 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:

  1. Step validate-workflow-run — loads the responder_workflow_runs row, atomically claims it (claimResponderWorkflowRun) so duplicate dispatches no-op.
  2. Step prepare-workflow — calls prepareWorkflowStep (defined later in the same file). Resolves teamId if missing; calls prepareCallData; sets the call's processing_status to 'processing'; loads the team's TypedWebhookConfig; instantiates the triager via WorkflowRunTriagerFactory.create(...); runs the triager's prepare() to determine outcomes and persist them as outcome_results rows. Returns a fully-serializable PrepareWorkflowResult (no class instances — Inngest serializes step output as JSON).
  3. Step execute-outcome-{i}-{type} per outcome row — calls executeOutcomeStep, which rebuilds the triager from the serialized prepared data via createTriagerFromPrepared, then calls triager.runOutcome(row). The triager's runOutcome calls the per-vertical executeOutcome(row) which is L4.
  4. Step finalize-workflow — calls finalizeWorkflowStep, which rebuilds the triager and calls triager.finalize(...) (corrects is_bookable, marks rows completed).
  5. Subsequent stepssend-inbound-call-completed event, 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_runs row stays QUEUED.
  • getTypedConfig returns wrong vertical/crm: the entire downstream chain misroutes. The prepare-workflow step's logs reflect the resolved vertical; visible in DD by filtering on the call's callId or workflowRunId.
  • A step throws after the workflow already produced a usable result: onFailure handler detects this (call?.result != null) and finalizes as processed, marking only the responder_workflow_runs row as FAILED. Avoids spurious Slack alerts.
  • All retries exhausted with no result: onFailure marks the call's processing_status: 'failed' and pages oncall via sendResponderSlackAlert.

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 vertical config 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_SERVICES default 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:

  1. Checks the outcome_results table for existing rows for this callId — if present, returns them (idempotency: a retry of L1 reuses the same outcomes, doesn't reclassify).
  2. Otherwise calls the abstract determineWorkflowOutcomes() (implemented per-vertical), validates the result, inserts the rows into Supabase as outcome_results with status pending, 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' && appointmentBookedrunBooking(...) → calls AutoOps POST /book
  • 'Rescheduling'stub: sets autoOpsAction='reschedule', logs "manual follow-up", does NOT call AutoOps
  • 'Cancellation'stub: sets autoOpsAction='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: callReason doesn't match any branch → the runner falls through with autoOpsAction = null.
  • Booking branch's appointmentBooked flag 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-Key is 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 AutoOpsBookingResult to 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

LayerFailure modeHow to detect
L0Webhook 4xx/5xxVapi delivery log; no responder_workflow_runs row inserted
L0DB row stuck at SCHEDULEDDirect query of responder_workflow_runs for old SCHEDULED rows; check EndOfCallReportCronInngestFunction health
L0Wrong teamId resolved or missingL1's prepare-workflow step throws "Team or assistant ID is required" — visible in Inngest run as a step failure
L1Function paused/rate-limitedInngest dashboard run status
L1Wrong vertical/crm in typedConfigprepare-workflow step's emitted logs (DD filter on callId or workflowRunId); compare to expected team config
L2New vertical not registered → falls through to HOME_SERVICESCompare team's vertical config vs factory cases
L3LLM mis-classificationInngest run step output: outcomes array
L4Unsupported CRM under verticalexecuteOutcome returns error result
L4row.type ignored when it shouldn't be (AUTO_SERVICE quirk)Check whether the runner uses row.type or recovers via separate extraction
L5Stub branch (current bug)Inngest step output shows autoOpsAction set but autoOpsResult.success === false and no AutoOps egress in DD
L5LLM extraction callReason doesn't match a branchDD log "AutoOps action selection" with empty/unmatched callReason
L6Network/5xx (re-thrown)Inngest step retried/failed
L6Business 4xx (graceful)Result {success:false, reason} recorded in data_store
L7Silent DB errorDD log only; calls row not updated
L7Email send failureEmail 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 inside rescheduling-st.ts itself.
  • 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.