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
read back and confirmed
tentative calendar hold
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):
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.
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.
spoken naturally
category "sms_consent"
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.
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.
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:
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:
./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.
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:
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.
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.