Skip to content

AutoOps post-call booking must never refuse an agreed-to appointment

One-line summary. When the voice agent verbally confirms an appointment, the post-call booking stage (run-booking-autoops.ts) must always create a job in AutoOps. Today a set of pre-flight guards (availability re-check, service eligibility, duplicate-customer refusal, missing vehicle/phone/service) throw and abort the booking, leaving the customer told "you're booked" with no job on the shop's board. AutoOps itself imposes no booking restrictions (duplicates allowed, slots not enforced), so every one of these rejections is self-inflicted. The fix flips guards from gates to annotations: verifications still run, but a failure no longer aborts. Missing data is filled with obvious dummy placeholders, the job is always created, and a prominent shop note lists everything a human needs to confirm.

The invariant

If the agent agreed to it, an appointment always lands — as long as we have a time. The outcomes are:

  1. Booked clean — all data present and valid.
  2. Booked with placeholders — the job exists, and a shop note enumerates each placeholdered/uncertain field.
  3. Not booked → special note to the shop — reserved for the ONE genuinely unbookable case: no appointment time was captured. A booking with no real "when" helps nobody, so we refuse and hand it to the shop to schedule manually.

The AutoOps job is the durable artifact so nothing is silently lost; the shop note is the to-do list of what to fix up. Every missing field except the appointment time gets a dummy placeholder.

Problem statement

For the EAS pilot (AutoOps, AUTO_SERVICE vertical), runAutoOpsBookingWorkflow calls runBooking() whenever extraction returns appointmentBooked: true. runBooking() currently throws — aborting the booking — at any of these points:

  • verifyScheduledTimeStillAvailable — the slot offered in-call is no longer in the post-call availability response.
  • validateServiceEligibility — matched service isn't flagged Waiter/DropOff eligible.
  • customer ambiguity — multiple phone matches → "refusing to create a duplicate customer".
  • 403 on customer lookup.
  • missing vehicle make/model/year.
  • no valid +1 phone and no matched customer.
  • no matched service.
  • missing/unconvertible scheduledAt.

On any throw, the workflow catches, sets autoops_error, fires a Slack alert to Kareem, and sends the shop the standard call-summary email with an error row. No job is created. The customer was told they were booked.

Sandy confirmed (2026-06-09): AutoOps does not stop a booking — duplicates are allowed, slot availability is not enforced server-side. The only thing preventing these bookings is our own logic.

Current behavior — failing case

Proposed behavior — guards annotate, booking always lands

The reframing: every guard becomes a non-blocking annotation

runBooking() accumulates shopNotes: string[] and never throws for a buildable reason. It always returns { jobId, shopNotes }.

Guard (still runs)OldNew
verifyScheduledTimeStillAvailablethrowbook anyway + note: "slot no longer showing available"
validateServiceEligibilitythrowbook anyway + note: "service marked ineligible for requested type"
customer ambiguity / 403 lookupthrowbook (dupes allowed) + note when a duplicate customer is created
vehicle missing make/model/yearthrowbook w/ placeholder vehicle + note: "vehicle unconfirmed"
no valid phone + no matched customerthrowbook w/ placeholder customer + note: "no valid callback number"
no matched servicethrowbook under the "Describe Issue" catch-all service + note: "service unconfirmed"
missing/unconvertible scheduledAtthrowstill throws → not booked, special note to the shop (the one genuinely unbookable case)

Placeholder convention

Obvious dummies a human instantly reads as "fill me in":

FieldPlaceholderNotes
customer nameJane DoefirstName: "Jane", lastName: "Doe"
phone+15555555555passes AutoOps's ^\+1[0-9]{10}$
vehicleUNKNOWN / UNKNOWN / <current model year>year must be numeric in 1900–next-model-year
servicethe "Described Issue" catch-all servicea real AutoOps service type (not a literal dummy — AutoOps validates the srvc_ id). Matched on the stable srvc_generic_describe_issue_<clientId> id prefix (per-location, so prefix-matched), name as backup. The customer's words + the shop note ride its issueDescription.
scheduledAtnone — refuse to bookthere is no honest placeholder for "when"; a booking with a fake time is worse than none. Route the special note to the shop instead.

"Describe Issue": a service, and a field — don't conflate them

Two distinct things share the name:

  • The "Describe Issue" service is an AutoOps service type — the generic catch-all booked when the requested service can't be matched. This is the service fallback.
  • issueDescription is a free-text field on AutoOpsBookService (types.ts, issueDescription?: string[]). This is the note channel — where the placeholder summary lands so it rides the job on the shop's board.

The placeholder summary is written into issueDescription regardless of which service was booked. Any real customer-reported description is preserved and the placeholder lines appended:

⚠️ Avoca AI — please verify before appointment:

  • Vehicle unconfirmed (booked as UNKNOWN/UNKNOWN)
  • No valid callback number captured
  • Service unconfirmed — agent requested "…", booked under "Describe Issue"

(Header is intentionally neutral: notes can be availability/eligibility caveats with zero placeholder data, per Greptile review on PR #11753.)

Email surface: two emails, not an error

A caveated booking is a successful booking. Threading the caveats into errorMessages branded the booking email [AUTOOPS ERROR] … with "There was an issue syncing this call to AutoOps" — wrong message for a job that landed fine. Revised design (Sandy, 2026-06-10):

  1. Booking email — the standard sendAutoServiceEmail "New Avoca AI Call" with full details, no error branding. When caveats exist it carries one extra row: Needs Verification: Some details need verification (see the "Action needed" email): <notes joined>.
  2. Exception email — new sendAutoOpsBookingExceptionEmail (booking-exception-email.ts), sent only when shopNotes is non-empty, to the same recipients. Subject [Exception - Action Needed]: Verify AutoOps booking <jobId> (<team>); body lists the caveats, points at the job's "Describe Issue" field, and links the call via getCallDetailsUrl(callId).

Failure isolation: the booking email sends first; an exception-email failure is logged, never thrown — the job's issueDescription remains the primary surface. errorMessages is reserved for genuine failures again.

Implementation notes

  • runBooking() return shape. Change from { jobId: string } to { jobId: string; shopNotes: string[] }. It throws only when the appointment time is missing/unparseable, or on a genuine AutoOps API/transport error — both land in the existing catch (Kareem Slack alert + shop email).
  • Guards → annotations. checkScheduledTimeStillAvailable and checkServiceEligibility (renamed from the old verify…/validate…) return a note instead of throwing. resolveCustomerInput drops the duplicate-refusal and 403 throws and falls through to create-new with notes. resolveVehicleInput placeholder-fills instead of throwing.
  • Service fallback. When matchAutoOpsService returns null, resolveService falls back to findDescribeIssueService(services) — matched on the srvc_generic_describe_issue_ id prefix (location-agnostic), name as backup — and annotates. The only unbuildable case is a shop with neither a match nor a "Described Issue" service.
  • scheduledAt. resolveScheduledAt throws when no time is captured or it can't be parsed — the one genuinely unbookable case. No sentinel.
  • Shop note delivery. Primary: buildIssueDescription appends shopNotes into the booking payload's issueDescription so they live on the AutoOps job. Secondary: the two-email surface above (verificationNotes param on sendAutoServiceEmail + sendAutoOpsBookingExceptionEmail) — NOT errorMessages, which is reserved for real failures.
  • Vehicle dedupe guard. Placeholder vehicles book with forceCreateNew: true so AutoOps can't attach the job to a different caller's existing UNKNOWN vehicle record; real vehicle data keeps forceCreateNew: false and may reuse the customer's vehicle (Greptile review, PR #11753).

AutoOps placeholder-acceptance test matrix (verify-before-trust)

Each placeholder must be confirmed against AutoOps prod with test discipline (far-future date, "Avoca Test" naming, never touch jobs we didn't create):

TestConfirms
book with placeholder vehicle (UNKNOWN/UNKNOWN/year)AutoOps accepts junk vehicle
book with +15555555555 new customerAutoOps accepts the dummy phone
book against a slot absent from availabilityslot not enforced (supports dropping the re-check)
book against the "Describe Issue" service regardless of eligibility flagseligibility not enforced server-side
book under the "Described Issue" service (srvc_generic_describe_issue_…)the prefix-matched fallback service is bookable

If any row fails, that field keeps a real-data path rather than the placeholder.

Follow-ups / risks

  • "Described Issue" service match. Matched on the srvc_generic_describe_issue_<clientId> id prefix — confirmed stable across every EAS location (only the cl_… client suffix varies), 2026-06-09. Display name "Described Issue" is the backup signal.
  • Double-bookings. Dropping the availability re-check + allowing dupes means the shop may see overlapping/duplicate jobs. Acceptable per Sandy (AutoOps allows it; the shop reconciles) — the shop note makes them visible.
  • No-time handoff. Missing appointment time is the only refuse-to-book path; it routes through the existing booking-failure alert + shop email. Make sure that copy reads as "please schedule manually," not just an error dump.
  • Kareem alert. Keep the existing Slack alert for the no-time case and genuine AutoOps API/transport failures, not for the annotation cases.