Skip to content

AutoOps Unbooked-Lead Call-to-Action Email

Status: MVP code-complete on avoca-next branch feat/autoops-unbooked-lead-cta (2026-06-29), handed to Jalen to finish/ship. Worktree worktrees/autoops-unbooked-lead-cta. Closes the lead-handling gap de-scoped from PR #12180: deferred/unbooked leads now actively nudge the shop instead of silently falling through. 12 unit tests + the existing booking-autoops/auto-service suites green (79/79).

Problem

Every AutoOps call already emails the shop via sendAutoServiceEmail. A booked call and an unbooked lead (a real bookable caller who never got an appointment) get the identical subject (New Avoca AI Call - …) and styling. So a lead that needs a callback does not stand out, and nothing tells the reader what to do about it. Real leads (e.g. the Jared oil-change deferral on 2026-06-16) quietly sink in the inbox.

"Unbooked" is also not one thing. A caller who was told a time that never landed is urgent; a caller who priced a service and hung up is a normal callback; a murky short call just needs a human to read it. One fixed "URGENT CALL BACK" stamp would cry wolf.

Approach

The per-call email gets a dynamic call-to-action computed from the call's own post-call outcome. "Unbooked" stops meaning "urgent" by default: only the told-booked-then-failed case is red.

Everything the CTA needs is already known at send time, so no new extraction or queries:

SignalSource
isBookable / isBookedderiveAutoServiceBookingFlags (already computed in updateAutoServiceCallRecord)
appointmentBookedextraction avoca.appointmentBooked
isTransferredgetForwardedPhoneNumber (already computed)
callReasonparsed auto-service reason
serviceOpportunityReasonextraction avoca.serviceOpportunityReason (the LLM's one-line "why this is a lead")
name / phone / service / promised timeextraction + caller id

Tier taxonomy

buildLeadCallToAction(outcome) is a pure function → { tier, headline, instruction }:

OutcomeTierColorHeadline (also the subject prefix)
Booked, or not a lead (excused / junk / reschedule / cancel / ETA)none(no banner; email unchanged)
Agreed a time, no job on the boardurgentredUnbooked — booking failed, act now
Transferred to a person, nothing landedcallbackamberUnbooked lead — follow up
Clear lead (booking intent / serviceOpportunity), never bookedcallbackamberUnbooked lead — call back
Bookable but inconclusivereviewblueUnbooked — review this call

The headline both prefixes the subject (so it reads as a lead in the inbox list) and renders as a high-contrast banner at the top of the email body. The instruction names the caller, their phone, what they wanted, and what to do.

Current sequence

Proposed sequence

Implementation

  • lib/workflow/stages/booking-autoops/lead-call-to-action.ts (new) — the pure tier function + types. No I/O, fully unit-tested (__tests__/lead-call-to-action.test.ts, 12 cases over the taxonomy + copy hygiene).
  • emails/SharedFormattedEmail.tsx — additive optional ctaBanner prop renders a tier-colored, high-contrast banner at the top. No-op (and visually unchanged) for every other consumer that omits it.
  • lib/workflow/stages/booking-autoops/auto-service-post-call.tsupdateAutoServiceCallRecord now returns the facts it already computes (isBooked/isBookable/isTransferred/callReason); sendAutoServiceEmail takes an optional leadCta and applies the subject prefix + banner.
  • lib/workflow/stages/booking-autoops/run-booking-autoops.ts — builds the CTA between the record write and the email send (only when the record write succeeded, so the flags are trustworthy).
  • scripts/preview-unbooked-cta.tsx — renders all four tiers to one static HTML for eyeballing: pnpm tsx scripts/preview-unbooked-cta.tsx [out.html].

Fail-safe by construction: if extraction or the record write fails, leadCta is undefined and the email renders exactly as it does today.

For Jalen — open follow-ups

  1. Live test: place a test call that ends unbooked (decline a time after pricing) and confirm the amber "call back" subject + banner render in the shop inbox. Hamming is the voice surface; the email itself can be smoke-tested with the preview script.
  2. Dedup vs the urgent email: the existing standalone sendAutoOpsBookingFailureUrgentEmail already fires red for the told-booked-then-failed case. With the urgent banner now on the per-call email, decide whether to keep both or retire the standalone. MVP keeps both.
  3. Phase 2 — explicit "call me back" signal: the "caller literally asked us to call them back" subset is currently folded into the amber callback tier. Detecting it as its own category cleanly is a small extraction-prompt field (followUpRequested), offline-evaluable (see the deterministic OpenAI eval pattern), rather than guessed in the email layer.
  4. Scope: this is AutoOps-only (the auto-service post-call path). The ServiceTitan / common-webhook email is a separate surface if the same treatment is wanted there.