Part 7 of 7 · Booking assistant series ~4 min read

Engineering reference: the booking assistant architecture

Same system as the rest of the series, drawn purely for engineers. Service names, resource identifiers, region, Bedrock model IDs, Google Calendar API choices, and the actual flow operations — everything you’d need to recreate this in your own AWS account.

Posts 1–6 walk through the system in plain language. This page is the dense version — nothing softened, just the architecture as you’d sketch it on a whiteboard during a design review.

Full technical architecture: serverless booking assistant in ap-southeast-1 A detailed engineering diagram of the entire booking assistant. Three external surfaces at the top: GitHub (repo and Actions runner, OIDC token requestor); Google Workspace (Drive config folder containing the service-rules file with services, durations, buffers, working hours, blackouts; Google Calendar with the staff member calendars; both reached via the same OAuth identity); and the public internet (booking customers reaching either an embedded web form on the marketing site, or sending email to the verified domain via SES inbound on the booking address). Everything runs in a single AWS account in region ap-southeast-1 (Singapore). The AWS account contains five subsystems. Build and Deploy strip at the top: GitHub Actions exchanges with IAM OIDC Provider, assumes an IAM Role with a trust policy scoped to repo:owner/repo:ref:main, and runs SAM/CloudFormation to update the booking-assistant-prod stack. Config Sync strip below: a Lambda Function URL named fn-config-sync receives Drive changes.watch notifications, validates the service-rules YAML, chunks and embeds the catalogue via Bedrock Titan Embeddings, and writes both the raw rules to S3 booking-assistant-data/config/ and the vectors to an S3 Vectors index named vec-services. Three runtime columns below. Reader and Parser (intake): the web form posts to a Lambda Function URL fn-intake-form that writes a structured request to DynamoDB tbl-requests, and SES Receipt Rule writes inbound email MIME to S3 booking-assistant-data/raw/ which triggers Lambda fn-intake-email which classifies the lane and on AI-handle invokes Lambda fn-parser; fn-parser retrieves catalogue chunks from S3 Vectors and invokes Bedrock global.anthropic.claude-haiku-4-5-20251001-v1:0 with strict tool_use over three tool extractors (extract_service, extract_window, extract_contact), then writes the parsed structured request to tbl-requests. Scheduler (per request): Lambda fn-scheduler runs on tbl-requests stream events for new structured requests, calls Google Calendar freebusy.query for the resources qualified to perform the requested service, applies the working-hours, duration-and-buffer, blackout, and capacity filters, ranks survivors, and writes 2-3 candidate slots back to tbl-requests; on no candidates it writes a decline outcome instead. Confirmer (per pick): Lambda fn-confirmer is invoked from a one-click confirm link on the proposal email/page, takes a conditional PutItem on tbl-claims keyed by (resource_id, slot_start_iso) with attribute_not_exists; on success it calls Google Calendar events.insert with a stable UUID set as the event id (so retries return 409 duplicate instead of double-inserting), then SES SendEmail with the .ics attachment and reschedule/cancel links, then EventBridge Scheduler CreateSchedule for one-day-before and one-hour-before reminder Lambdas; on conflict it returns to the proposal stage with fresh slots. Cross-cutting bottom strip: DynamoDB tables tbl-requests, tbl-claims, tbl-audit log every request, claim, and action; CloudWatch Logs are configured with RetentionInDays of 7 across every log group; SNS topics t-alarms and t-drafts notify on failures and pending drafts; AWS Budgets has a $10 monthly alarm; Lambda fn-archive runs on a separate weekly cron 0 3 SUN to move old request blobs to S3 Glacier Instant Retrieval storage class. GitHub github.com/owner/repo Actions runner · OIDC token requestor Google Workspace Drive: rules.yml · Calendar: staff cals OAuth service account · domain-wide delegation Customers web form · email to verified domain two surfaces, one shape downstream AWS Account Region: ap-southeast-1 (Singapore) · Bedrock via Global CRIS Build & Deploy IAM OIDC Provider token.actions.githubusercontent.com IAM Role trust: repo:owner/repo:ref:main SAM / CloudFormation stack: booking-assistant-prod git push & request token AssumeRole sam deploy → creates stack resources below Config Sync Lambda Function URL fn-config-sync (chunks + embeds) Bedrock Titan Embeddings amazon.titan-embed-text-v2:0 S3 Vectors vec-services (catalogue index) changes.watch notification Reader & Parser (intake) Lambda Function URL fn-intake-form (web POST) SES Receipt Rule + S3 booking-assistant-data/raw/ ObjectCreated AWS Lambda fn-intake-email + fn-parser structured request DynamoDB tbl-requests (PAY_PER_REQUEST) → stream event triggers Scheduler web POST or email Scheduler (per request) DynamoDB Streams tbl-requests event AWS Lambda fn-scheduler (per request) freebusy.query Google Calendar v3 freebusy + events.list filter + rank 5 filters in order hours · dur+buffer · blackouts · capacity · rank → 2-3 slots written to tbl-requests customer sees proposal email or web reply Confirmer (per pick) Lambda Function URL fn-confirmer (signed link) step 1: claim DynamoDB tbl-claims · conditional PutItem step 2: write Google Calendar v3 events.insert + UUID event id step 3: confirm SES SendEmail .ics + reschedule/cancel links step 4: remind EventBridge Scheduler T-1d, T-1h one-time schedules → customer is booked, calendar is right catalogue feeds fn-parser Cross-cutting DynamoDB tbl-requests, tbl-claims, tbl-audit CloudWatch Logs RetentionInDays: 7 SNS t-alarms, t-drafts AWS Budgets budget-monthly: $10 Lambda fn-archive EventBridge cron(0 3 ? * SUN *) → old request blobs to S3 Glacier Instant Retrieval
Fig 7. Full architecture, ap-southeast-1. White boxes = AWS resources; dashed AWS container; dashed grey boxes = subsystem groupings; dashed grey arrows = config feed and side branches.

Read this top-down, then column-by-column

Top row is the three external surfaces. Below it, the AWS account contains five subsystems: Build & Deploy across the top, then Config Sync, then three runtime columns (Reader & Parser, Scheduler, Confirmer), with a Cross-cutting strip at the bottom. A request arrives either via the form (POST to fn-intake-form) or via email (SES → S3 → fn-intake-email), is parsed if needed, and is written as a structured row into tbl-requests. The DynamoDB stream then triggers fn-scheduler, which queries Google Calendar’s freebusy API and applies the five filters from part 4. The proposal goes back to the customer with a one-click confirm link; tapping it invokes fn-confirmer, which runs the four-step claim/write/confirm/remind sequence from part 5.

Naming conventions used in the diagram

  • Lambda functions: fn-<purpose>fn-intake-form, fn-intake-email, fn-parser, fn-scheduler, fn-confirmer, fn-config-sync, fn-archive.
  • DynamoDB tables: tbl-requests (every inbound request, with parsed shape, slots, and outcome), tbl-claims (the atomic-claim ledger keyed by (resource_id, slot_start_iso), with TTL), tbl-audit (every action ever taken).
  • SNS topics: t-alarms for general failures, t-drafts for parser-low-confidence drafts that need a human approval.
  • S3 layout: single bucket booking-assistant-data with prefixes raw/{date}/, config/, archive/.
  • S3 Vectors index: vec-services — chunked + embedded service catalogue for parser retrieval. The index lives inside an S3 vector bucket; the bucket is the parent resource, the index is what you query.

Region, model access, and Google Workspace auth

Everything runs in ap-southeast-1 (Singapore) for low latency from the Philippines. Bedrock model invocations use the Global cross-Region inference profile (model IDs prefixed with global.) — data at rest stays in Singapore; inference may route to other regions for capacity. Pricing is the same as on-demand Singapore pricing.

Google Workspace authentication uses a service account with domain-wide delegation, granted exactly three scopes: https://www.googleapis.com/auth/calendar.events to write events, https://www.googleapis.com/auth/calendar.events.freebusy to read freebusy on those calendars (the broader calendar.events scope alone is not authorised for freebusy lookups), and https://www.googleapis.com/auth/drive.readonly for the rules file. The service account’s private key is stored in AWS Secrets Manager and never leaves the runtime; rotation is a single CloudFormation parameter update.

The parser uses strict tool_use: three tool definitions (extract_service, extract_window, extract_contact) with required parameter schemas including a confidence_score in [0, 1] per field. The model can only emit structured tool calls — not a free-text reply. Free text would let it invent service IDs or hallucinate phone numbers; tool_use makes that mathematically impossible.

What’s deliberately not on the diagram

  • IAM policy details — per-Lambda execution role inline policies are minimal (one bucket prefix, one or two tables, SES on a single sending identity, Bedrock invoke on one model, Secrets Manager read on the Google service-account secret).
  • Per-business rules schema — rules.yml is a single Drive doc with sections for services, working hours, blackouts, capacity caps, ranking preferences, and tone. Updating sections updates the assistant’s behaviour without a deploy.
  • X-Ray tracing — on for fn-parser, fn-scheduler, and fn-confirmer, sampling 100% during tuning, 10% in steady state.
  • Microsoft 365 swap-in — if you’re on Outlook/Exchange, replace the Google Calendar v3 calls with Microsoft Graph getSchedule and events.create. Same shape, different SDK, same idempotency story.
  • Bedrock Knowledge Bases — managed retrieval that can replace the explicit S3 Vectors + Titan Embeddings setup with a single connector. Worth picking when the bring-your-own-rules-file pattern isn’t strictly required; the catalogue is small enough that the explicit path is also fine.
  • Bedrock Guardrails contextual grounding check — managed grounding-and-relevance scoring. The custom confidence_score per field in fn-parser is roughly the same idea hand-rolled; swapping to Guardrails moves the thresholds into console configuration and adds PII redaction on every model call. Worth turning on once the in-code thresholds are stable.
  • SMS lane — an SMS surface (Twilio number → Lambda Function URL) slots in next to the email and form intakes with no other architectural change. Keeping it off the default diagram so the per-request cost stays in the always-free band.

If you’re recreating this

Start with Build & Deploy alone (a single Lambda, no triggers). Once git push reliably updates an empty stack, get a Google service-account credential into Secrets Manager and verify a hard-coded freebusy call from a Lambda. Then the form intake and a stub scheduler that always returns three made-up slots. Then the real five-filter scheduler. Then the claim/write/confirm/remind confirmer (one step at a time — the claim’s atomic write is the part most worth integration-testing with parallel invocations). Then the email lane and the parser. Cross-cutting (audit, logs, alarms, budget, archive) goes in from day one.

All posts