Appearance
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:
- Booked clean — all data present and valid.
- Booked with placeholders — the job exists, and a shop note enumerates each placeholdered/uncertain field.
- 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".
403on customer lookup.- missing vehicle make/model/year.
- no valid
+1phone 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) | Old | New |
|---|---|---|
verifyScheduledTimeStillAvailable | throw | book anyway + note: "slot no longer showing available" |
validateServiceEligibility | throw | book anyway + note: "service marked ineligible for requested type" |
customer ambiguity / 403 lookup | throw | book (dupes allowed) + note when a duplicate customer is created |
| vehicle missing make/model/year | throw | book w/ placeholder vehicle + note: "vehicle unconfirmed" |
| no valid phone + no matched customer | throw | book w/ placeholder customer + note: "no valid callback number" |
| no matched service | throw | book under the "Describe Issue" catch-all service + note: "service unconfirmed" |
missing/unconvertible scheduledAt | throw | still 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":
| Field | Placeholder | Notes |
|---|---|---|
| customer name | Jane Doe | firstName: "Jane", lastName: "Doe" |
| phone | +15555555555 | passes AutoOps's ^\+1[0-9]{10}$ |
| vehicle | UNKNOWN / UNKNOWN / <current model year> | year must be numeric in 1900–next-model-year |
| service | the "Described Issue" catch-all service | a 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. |
scheduledAt | none — refuse to book | there 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.
issueDescriptionis a free-text field onAutoOpsBookService(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):
- 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>. - Exception email — new
sendAutoOpsBookingExceptionEmail(booking-exception-email.ts), sent only whenshopNotesis 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 viagetCallDetailsUrl(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.
checkScheduledTimeStillAvailableandcheckServiceEligibility(renamed from the oldverify…/validate…) return a note instead of throwing.resolveCustomerInputdrops the duplicate-refusal and403throws and falls through to create-new with notes.resolveVehicleInputplaceholder-fills instead of throwing. - Service fallback. When
matchAutoOpsServicereturns null,resolveServicefalls back tofindDescribeIssueService(services)— matched on thesrvc_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.
resolveScheduledAtthrows when no time is captured or it can't be parsed — the one genuinely unbookable case. No sentinel. - Shop note delivery. Primary:
buildIssueDescriptionappendsshopNotesinto the booking payload'sissueDescriptionso they live on the AutoOps job. Secondary: the two-email surface above (verificationNotesparam onsendAutoServiceEmail+sendAutoOpsBookingExceptionEmail) — NOTerrorMessages, which is reserved for real failures. - Vehicle dedupe guard. Placeholder vehicles book with
forceCreateNew: trueso AutoOps can't attach the job to a different caller's existing UNKNOWN vehicle record; real vehicle data keepsforceCreateNew: falseand 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):
| Test | Confirms |
|---|---|
book with placeholder vehicle (UNKNOWN/UNKNOWN/year) | AutoOps accepts junk vehicle |
book with +15555555555 new customer | AutoOps accepts the dummy phone |
book against a slot absent from availability | slot not enforced (supports dropping the re-check) |
book against the "Describe Issue" service regardless of eligibility flags | eligibility 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 thecl_…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.