Booking and compliance

Elle's job is not to chat; it's to get interested people booked for a short intro call with the owner. That means touching a real calendar, sending real texts, and doing both inside carrier and consent rules. This page covers the booking machinery and the compliance engineering around it.

The booking flow

Intereston any channel
Details gatheredname · email · specific time,
read back and confirmed
book_intro_callfree/busy check →
tentative calendar hold
Owner confirmscaller told "pending
confirmation by email"

Every hold is explicitly tentative. The AI never finalizes a meeting on its own, the human confirms, and everyone is told so.

The tool's contract encodes the guardrails, so they hold on every channel that exposes it (phone, web, SMS):

src/booking/booking.ts
export const BOOK_INTRO_CALL = {
  name: "book_intro_call",
  description:
    `Tentatively book a short intro call ... Call this once the caller has agreed
     to meet and you have their name, email, and a specific proposed start time
     that you have read back to them and they confirmed. The slot is created as
     TENTATIVE and is NOT confirmed until ${ownerName} approves it ...`,
  parameters: { ... name, email, start (ISO 8601 with offset), topic ... },
}

Execution validates the email shape, checks the slot against the owner's real availability (a Google Calendar free/busy query), and creates the event titled "pending confirmation". If no calendar is configured, the request is still captured as a lead, the pipeline degrades to note-taking instead of failing. Every outcome writes a booking row (status: "pending") that the owner reviews in the admin views, and a paired browser gets a live notification of the request.

CallerElle (model)booking.tsGoogle Calendarsms.ts"Tuesday at 2 works" + name + emailreads date, time, timezone back → clear yesbook_intro_call(name, email, start ISO+offset)validate email shape + timestampfreeBusy(start, +30 min)slot freeinsert event "pending confirmation"bookings row · status = pendingtool result: TENTATIVE, say owner confirms by emailfull consent script → plain yes-or-nosave_user_fact("sms_consent") → confirmation SMSconsent fact + confirmation text = the audit trail; STOP/HELP enforced by Twilio upstream
The AI never finalizes: every hold is pending until the owner confirms, and texts require the spoken consent script.

Timezones, handled once

Callers say "Tuesday at two"; calendars need offsets. The per-call context block gives the model the current UTC time, the owner's timezone, and the meeting length, and requires the proposed start as ISO 8601 with the correct offset. Times are always read back with the timezone before booking.

Identity rules that came from a real bug

Facts are keyed by phone number, and phones get shared. After a real mix-up where Elle greeted a caller by another person's name, the persona gained a hard rule: stored notes are context, never identity. The current caller has no name until they state one in this conversation, and a booking's name must come from the conversation itself, if the caller never gave one, Elle asks "who should I put this under?" rather than filling it in from notes.

SMS consent: the verbal opt-in flow

Sending someone booking texts requires consent that satisfies carrier (A2P) rules, and the consent conversation itself has required elements. After a successful booking on a call, Elle offers text updates conversationally but completely: what the texts are about, that frequency varies, that message and data rates may apply, HELP and STOP keywords, and where the full terms live. Then a plain yes-or-no question.

Booking succeedson a phone call
Consent scriptall required elements,
spoken naturally
Clear "yes"only then: save_user_fact
category "sms_consent"
Confirmation SMSsent automatically,
consent on record

A "no" is dropped gracefully and never re-asked on the same call. The consent fact plus the confirmation text form the audit trail.

The wiring is deliberately mechanical: saving the sms_consent fact is the trigger that fires the enrollment-confirmation text, so consent and its confirmation cannot drift apart. Inbound STOP/HELP are enforced by Twilio's opt-out handling upstream, the application cannot forget to honor them. On a bilingual call, the consent elements are delivered in the caller's language (see bilingual mode).

Why a text-mediated pipeline matters here

This page is the practical argument for the cascade architecture: consent scripts and booking read-backs must be delivered as written. Because every spoken word passes through text the system controls, exact delivery is enforceable, the property a fused speech-to-speech model gives up when it paraphrases.

Verified offline

booking.verify.ts proves the flow against a fake calendar: a free slot creates a tentative hold and a pending booking row with the "confirm" messaging; a busy slot declines and proposes alternatives; a missing calendar still captures the lead. The SMS agent's tool loop and the consent-confirmation trigger run under the same offline harness style.

Reading the time the caller meant

The tool asks the model for an ISO 8601 start with an offset, and the persona puts the current time and the owner's timezone in context so it can produce one. Models drop the offset anyway, and a bare 2026-08-28T14:00:00 is parsed by JavaScript as local time. Local time on Cloud Run is UTC. So a caller who asked for two in the afternoon was booked for seven in the morning their time, silently, right after Elle read "two o'clock" back to them and they agreed. Nothing surfaces that until the meeting is missed.

A start that carries its own offset is now trusted exactly as written; one that does not is read as wall-clock time in the owner's timezone, which is what both the caller and the persona meant. The zone's offset is taken at the booked date rather than today, so a January booking made in August gets standard time and not daylight time.

src/booking/booking.ts
export function parseStart(raw: string, timeZone: string): Date | null {
  const s = raw.trim().replace(" ", "T");
  if (HAS_OFFSET_RE.test(s)) { ... }          // trust it
  const naive = new Date(s + "Z");            // read the wall clock
  const first = new Date(naive.getTime() - zoneOffsetMs(naive, timeZone));
  return new Date(naive.getTime() - zoneOffsetMs(first, timeZone));  // settle DST
}

Two sanity bounds sit alongside it. A start in the past is refused, because models get the year wrong and a caller confirming "the 28th" cannot hear which year was heard. So is one more than a year out, for the same reason from the other direction.

The conflict check deliberately fails open: if the free/busy read itself errors, the booking still goes ahead. Losing a caller who is ready to commit is worse than a hold the owner may have to move, and every hold is confirmed by hand anyway.

Whose credentials, and with which scopes

Two things routinely get conflated when this fails, and they have different fixes.

Scope is not permission. A 403 saying insufficient authentication scopes does not mean the calendar is unshared; it means the token was issued without auth/calendar on it, so the request never got as far as checking access. Default Application Default Credentials carry cloud-platform and not calendar, and for user credentials the scopes are fixed at login: asking for a scope in code afterwards does nothing. The fix is to log in again and ask for it.

shell
gcloud auth application-default login \
  --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar

Identity is not permission either. Running the check on a laptop or in Cloud Shell exercises your account. Elle books as the service account the Cloud Run service runs as, and that is the account the calendar must be shared with. Your own access proves the calendar id is right and the API is on; it proves nothing about whether Elle can book. To check the identity that matters, either impersonate it, which needs Service Account Token Creator, or ask the deployed service, which is already running as it:

shell
gcloud auth application-default login \
  --impersonate-service-account=SERVICE_ACCOUNT_EMAIL \
  --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar

# or, simplest: the Booking row here does a live free/busy call as the service
/admin/status

Granting your own account the Calendar scope through gcloud is also a dead end worth naming: Google refuses sensitive scopes to the shared gcloud OAuth client and blocks the consent screen. That block is correct behaviour and unrelated to whether booking works, and the way past it is not to work around it with an OAuth client or a downloaded key, but to stop authenticating locally at all.

So the authoritative check runs inside the service, where the credentials are already the right ones and no consent screen is involved:

shell
./tools/check-calendar.sh

That looks up the service URL and the admin token itself and prints the report. It takes no arguments on purpose: a documented command containing a placeholder is a command someone eventually pastes verbatim, and the shell reads < as a redirect rather than as "fill this in".

It walks the same five steps as the command-line tool, because it is literally the same function (src/booking/calendarCheck.ts); only the identity differs, which is the whole point. It creates one throwaway hold six months out, confirms it reads as busy, moves it, deletes it, and reports which step failed and whether the failure was about scope, about access, or about the calendar id. The local tool remains useful for confirming the calendar id and that the API is on, and it now says plainly that it ran as you.

An unreadable calendar is not a free calendar

The free/busy endpoint answers 200 OK even when it could not read the calendar, and reports that per calendar instead, as an errors array on the entry. Reading the busy list straight out of that response gets an empty list, and an empty busy list means free. So a calendar the account could not see at all reported as wide open.

The consequences were quiet, which is what makes it worth writing down. The conflict check passed on a calendar nobody could read, so it protected nothing. The self-check printed OK, slot is free immediately above a booking that then failed with a 404, which is a contradiction that took a live run to notice. And no caller was ever affected, because the failure showed up one step later as a lost hold, attributed to the wrong cause.

src/booking/calendar.ts
const entry = data.calendars?.[calendarId];
if (!entry) throw new Error(`freeBusy returned no data for ${calendarId}...`);
if (entry.errors?.length) throw new Error(`freeBusy could not read ${calendarId}...`);
return (entry.busy ?? []).length === 0;

An unreadable calendar now raises. npm run verify:calendar asserts both shapes, the per-calendar errors array and a response with no entry at all.

The guest that cannot be invited

Every booking attaches the caller as a guest on the hold. Google refuses that for a plain service account:

Google Calendar API
403 Service accounts cannot invite attendees without
    Domain-Wide Delegation of Authority.

That restriction applies to any calendar simply shared with the service account, which is the normal arrangement for a personal calendar, and delegation requires a Workspace domain so it is not available there at all. Left alone, this fails the whole insert, which the booking handler catches, so every booking quietly degrades to a lead: no hold ever reaches the calendar while the caller has been told their time is held.

The client now retries once without the guest. The hold is what matters, and the caller's email is in the event description either way, so the owner adds them when confirming and sending the invite. Any other failure, a read-only share or a wrong id, still throws rather than being retried around, because those are real misconfigurations that should be visible.

The live check attaches a guest to its probe hold for exactly this reason. A probe without one takes a different path from the thing being tested and would report green while every real booking failed.

Checking it actually works

npm run verify:booking proves the orchestration against a fake calendar, with no network: tentative holds, busy slots refused, bad input rejected, leads captured when no calendar is configured, and the time handling above. What it cannot prove is that a given deployment can reach the real calendar, and the things that break in production all live on that side of the fake: the API not enabled, the calendar not shared with the service account, shared read-only rather than "make changes", the wrong id.

./tools/smoke-calendar.sh walks those in order against the real calendar and names the step that broke. It reads free/busy, creates a tentative hold six months out, checks the hold now reads as busy (if it does not, the double-booking protection is not protecting anything), moves it the way a reschedule by text would, and deletes it. Nothing is left behind and nobody is emailed, because every write uses sendUpdates=none.