Skip to main content
Back to Portfolio
Project

Foyer

Conversational booking agent for local-service businesses — SMS and embedded web chat run one intake conversation, hold a slot for 15 minutes against a Postgres exclusion constraint, and wait for a one-click owner confirm before locking it in Google Calendar.

Laravel 13PHP 8.3PostgreSQLbtree_gistFilament v4SanctumRedisHorizonFastAPIPython 3.12TwilioGoogle Calendar APIPreactTypeScriptNext.js 16React 19OpenAPI 3.1
Foyer preview

Overview

A portfolio build shaped for the small-service-business owner — plumber, cleaner, dog walker — whose job listing reads "AI booking bot for my service business." The demos on the market for that listing all fail one of two ways, and Foyer is the answer to both.

The wedge: every "AI booking bot" either auto-books — committing slots the moment a customer asks and filling the calendar with ghost appointments — or chats without ever touching a calendar. Foyer is the honest middle. It runs the intake conversation, holds a tentative slot for 15 minutes, validates address and service area, and puts a one-click confirm gate in front of the owner before anything locks. The double-booking guard is a database constraint, not a hopeful if.

The headline move:

Correctness by constraint. Double-booking is prevented by a Postgres EXCLUDE USING gist constraint covering pending/confirmed bookings and active 15-minute slot holds — not application logic. Two customers racing for the same slot: the second insert fails at the database level and the agent re-searches. The test suite runs concurrent inserts against a real Postgres and asserts exactly one wins; the constraint is never mocked, because mocking it defeats the point.

Three processes, one database — a tested boundary:

Laravel 13 owns all schema, every migration, and every write. A FastAPI (Python 3.12) sidecar runs the LLM state machine and never opens a Postgres connection — it reads turn context and posts results back over a 127.0.0.1-bound, HMAC-signed internal API (X-Foyer-Internal-Sig). Twilio outbound is a Horizon queue worker, not a fourth process. The AgentTurn job payload is JSON-Schema'd and validated on both enqueue and dequeue, with PHP and Python codegen from one schema and CI gating parity.

Human-in-the-loop confirm:

The owner sees each pending booking in a Filament inbox — transcript, geocoded address with map preview, customer photos, proposed slot. One click confirms and writes the event to Google Calendar; one click rejects with a reason that goes back to the customer. POST /v1/bookings/:id/confirm requires an Idempotency-Key header — a double-click collapses to exactly one Calendar write and one SMS.

Two channels, one conversation:

SMS via Twilio, with MMS photo intake, and an embedded Preact widget (≤ 30 KB gzip, Shadow-DOM isolated). The same state machine drives both. Cross-channel resume requires a one-time code — matching a phone number alone is a spoofing vector.

Delivery reliability and compliance:

Webhook ingestion is queue-fast-ack — validate the Twilio signature, dedupe on external_id, dispatch the job, return 200 within 500 ms; the LLM work runs out-of-band so carrier retries never double-process. STOP / START / HELP consent keywords are enforced before any agent dispatch, keyed on the (customer, twilio_number) pair. Quiet hours default to an FCC-aligned 21:00–08:00 window, overridable per business. Scope guardrails — service types, service area, business hours, blocked dates, lead-time bounds, kill switch — are validated server-side; a garbage config cannot be saved. Sending runs on a registered 10DLC brand.

Live demo:

foyer.philiprehberger.com/demo runs a fictional "Anchor Plumbing — Boulder, CO" fixture: a booking line, the embedded web chat, and a read-only owner inbox showing a live pending-booking card with a hold-expiry countdown, a recent-slot-activity table (pending owner confirm / owner confirmed / fallback out-of-scope), and an honest "what this demo will and will not do" box.

Stack:

  • Laravel 13, PHP 8.3, Filament v4, Sanctum on PostgreSQL 16 with btree_gist, Redis, Horizon
  • FastAPI agent worker (Python 3.12) — LLM state machine, structured-output retry + DLQ, per-business cost ceiling, prompt-injection screen
  • Twilio Programmable SMS/MMS; Google Calendar (events.watch push + fallback poll) + Google Geocoding
  • Preact + TypeScript + Shadow-DOM widget (≤ 30 KB gzip, CI-gated); Next.js 16 + React 19 for docs, marketing, and the live demo
  • Hand-authored OpenAPI 3.1 spec as the contract source of truth; RFC 7807 problem responses
  • Apache + php-fpm 8.3 + atomic-release deploys for the API; PM2 + rsync for docs; supervisord for Horizon + the FastAPI worker; Sentry across PHP, Python, and JS

What it proves:

Same person designed the multi-process service boundary and wrote the HMAC internal-API middleware, authored the Postgres exclusion-constraint schema and the concurrency test that exercises it, built the FastAPI agent state machine with structured-output retry and cost ceilings, wired Twilio inbound with queue-fast-ack + STOP/START/HELP consent, integrated Google Calendar with drift detection and Geocoding service-area enforcement, built the Filament owner inbox with idempotent confirm, built the ≤ 30 KB Shadow-DOM widget, hand-authored the OpenAPI 3.1 contract, and deployed it live. The case for hiring me to build a booking agent that respects the calendar instead of bolting a no-code chatbot onto a problem it can't solve.

Every AI booking bot either books ghosts or books nothing. Foyer holds the slot for fifteen minutes, asks the owner for a one-click confirm, and guards double-booking with a Postgres constraint — not a hopeful if.

Results

  • Double-booking prevented by a Postgres EXCLUDE USING gist constraint over pending/confirmed bookings + active 15-minute slot holds — exercised by concurrent inserts against a real Postgres, never mocked

  • Three-process boundary (Laravel writer / FastAPI compute / Twilio Horizon worker) over a 127.0.0.1-bound HMAC internal API; the agent worker never opens a DB connection

  • Human-in-the-loop confirm — Filament owner inbox with transcript, geocoded address, photos, proposed slot; POST confirm is Idempotency-Key-gated so a double-click collapses to one Calendar write + one SMS

  • Queue-fast-ack webhook ingestion — Twilio signature validated, deduped on external_id, 200 returned within 500 ms, LLM work out-of-band

  • Two channels, one state machine — Twilio SMS/MMS + a ≤ 30 KB Shadow-DOM Preact widget; cross-channel resume gated by OTP

  • STOP/START/HELP consent keyed on (customer, twilio_number), FCC-aligned quiet hours, server-validated scope guardrails, per-business kill switch — sending on a registered 10DLC brand

  • FastAPI agent state machine with structured-output retry + DLQ, prompt-injection screen, and a per-business per-day LLM cost ceiling

Gallery

Foyer screenshot 2
Foyer screenshot 3
Foyer screenshot 4
Let's talk

Interested in working together?

Let's discuss how I can help with your project.

Send a message