One Workflow. Three Channels. Real Booking Infrastructure.
Experimental source tour
The Boulevard package passes its current TypeScript and test suites. CI validates the live provider contracts with deterministic transport doubles; it does not create an external Boulevard appointment.
An experimental Boulevard booking architecture where Web UI, AI Chat, and Gemini Live Voice target the same workflow.
The channels interpret interaction. The runtime owns execution.
text
Source code: /invariant/examples/boulevard-booking💡 The Core Idea
A customer can book the same salon appointment through a web UI, chat conversationally with an agent, or speak naturally via browser-direct Gemini Live audio.
The channel changes. The booking workflow does not.
text
Web UI (Calendar / Dropdowns) ─────┐
│
AI Chat Agent (Text Intent) ───────┼──► Runtime Boundary ──► Booking Workflow ──► Boulevard API
│
Gemini Live (Direct Speech) ───────┘Each channel only translates user interaction into proposed input for the current workflow boundary.
No channel owns booking state, decides which step comes next, or bypasses workflow validation.
Different channels. Same execution truth.
Boulevard Runtime Modes
The example does not treat “Boulevard is configured” as a single boolean. It reports three independent contracts through GET /api/health and the runtime inspector:
| Contract | Simulated/default | Live |
|---|---|---|
| Inventory, client lookup, cart, and reservation | No Boulevard credentials | Valid API key, secret, and business ID |
| Add a new card | BOULEVARD_PAYMENT_MODE=mock | BOULEVARD_PAYMENT_MODE=live plus the Boulevard browser tokenization URL |
| Complete cart/create appointment | BOULEVARD_CHECKOUT_MODE=simulated | BOULEVARD_CHECKOUT_MODE=live |
With live Boulevard credentials, both modes are fail-closed: an unset value is reported as disabled. The workflow will not accept a local card or emit a simulated appointment confirmation until the operator explicitly selects mock/live payment and simulated/live checkout.
A returning client can use a real Boulevard saved payment method without enabling new-card ingress. With live credentials and live checkout, the workflow performs client lookup, explicit identity verification, takeCartOwnership, imports availablePaymentMethods, calls selectCartPaymentMethod, and completes the cart through checkoutCart. The API acknowledgement—not the model—authorizes the final confirmation.
The bundled Add Payment Method form uses an explicit demo vault while payment ingress is mock. Therefore, when checkout is live but payment ingress remains mock, the form is hidden and checkout is saved-method-only. To add a card in sandbox, set BOULEVARD_PAYMENT_MODE=live on the server and build the web client with:
text
VITE_BLVD_TOKENIZATION_URL=https://vault-sandbox.joinblvd.com/cards/tokenizeThe browser posts the card fields directly to Boulevard and sends only the returned token to the guarded application ingress. Production uses https://pci.boulevard.app/cards/tokenize and requires an explicit PCI-scope review or provider-hosted fields.
Client lookup tries normalized and US display phone forms, then Boulevard's exact emails filter. The submitted phone and email must resolve to the same client. Boulevard passwordless verification must settle before authenticated cart ownership exposes provider payment methods. Live credentials disable the John Doe/Visa 4242 fixture unless BOULEVARD_DEMO_FIXTURES=true is explicitly set. The demo and Control Plane must use the same BOULEVARD_BUSINESS_ID and credentials to resolve the same customer profile.
Without BOULEVARD_API_KEY, every Boulevard capability uses deterministic mock data and the transport performs zero network requests. Inventory, cart mutations, identity, payment ingress, simulated checkout, appointment listing, cancellation, and rescheduling remain runnable even if a stale *_MODE=live setting is present.
The demo hero publishes the local test fixtures:
| Fixture | Value | Local mock outcome |
|---|---|---|
| Client | John Doe · johndoe@gmail.com · 301-453-5400 | Returning client with saved Visa 4242 |
| Verification | 000000 | Accepted in demo identity mode |
| Approved card | 4242 4242 4242 4242 · 12/28 · CVC 123 · ZIP 10013 | Attached to the mock vault |
| Declined card | 4000 0000 0000 0002 · 12/28 · CVC 123 · ZIP 10013 | Rejected before attachment |
These payment outcomes apply only to the local mock vault. Live Boulevard tokenization uses provider-specific sandbox card data; never enter a real card in the demo.
Booking and existing-appointment identity
Both returning-client booking and appointment-management use Boulevard passwordless authentication. The runtime requires the submitted phone and email to match the same client, calls clientPasswordlessAuthMethods, requests a code with APPOINTMENT_DETAILS, and validates it with clientPasswordlessAuthSignIn before cart ownership, saved-card disclosure, or appointment lookup. Caller-provided userUid and boulevardClientId fields are not accepted as proof of identity.
The raw code enters only the guarded /api/sessions/:id/verify-identity Host route. The Host exchanges it immediately and submits a one-time, run-scoped opaque receipt to the durable boundary. Direct /input, deterministic chat, and model proposals cannot submit that boundary. The provider token and raw code are excluded from durable state, logs, and projections. Only the verified client ID crosses into cart ownership or appointment lookup. The workflow then authorizes the selected appointment/action and offered reschedule date/time against its fresh provider state.
Live management is fail-closed:
- an authoritative empty list renders “No Active Appointments”; it never loads demo appointments;
- a failed list request exposes no appointment data;
- cancellation is reported only after Boulevard returns the same appointment;
- rescheduling is reported only after Boulevard returns the same appointment and its new start time.
Demo identity mode uses BOULEVARD_IDENTITY_MODE=mock and BOULEVARD_DEMO_OTP (default 000000). It can be combined with live sandbox inventory, but is ignored outside the Boulevard sandbox region. The browser shows the demo hint without copying it into durable state, API responses, or chat history. Live identity mode never projects the code. See Boulevard's passwordless Client API mutations for the provider contract.
Provider payment methods are deduplicated by card fingerprint and include their expiration in the label so cards sharing a brand and last four digits remain distinguishable. If checkout is disabled, selection and new-card controls are disabled before mutation. A terminal checkout failure is projected visibly and a bare “continue” cannot silently start a replacement booking.
Time, terminal outcomes, transcript, and refunds
- Availability uses the location IANA timezone when known. Otherwise
tz: nulldelegates wall-clock authority to Boulevard's cart location. Formatting reads Boulevard's calendar/clock components directly, so host timezone conversion cannot turn noon into 9:00 AM. - Reservation succeeds only if Boulevard returns the same offered wall-clock slot. A shifted result fails before checkout.
- Booking, cancellation, rescheduling, no-appointment, and identity-denial outcomes are terminal projections—not new
.wait()nodes. A completed run exposesstart_workflow, notcancel_workflow. - UI clicks append sanitized causal actions before the next assistant boundary; provider IDs, contact details, raw OTPs, and card tokens are excluded from the transcript copy.
- Refund execution is outside the workflow. The agent may only render the application-owned staff escalation message and must not equate cancellation with a refund.
🔍 Follow One Booking: "Selecting an Appointment Date"
To understand why this architecture is so powerful, let's trace a single step through all three channels.
The master workflow is currently suspended at a .wait() boundary:
ts
.capability('get-bookable-dates', getBookableDatesCapability)
.wait<{ selectedDate: string }>('select-date-ui', {
schema: SelectDateSchema,
presentation: {
type: 'date',
label: 'Appointment Date',
prompt: 'Which date works best for your appointment?',
},
})Here is how each channel interacts with that exact same boundary:
1. Web UI (Deterministic Component)
The widget receives the SessionSnapshot via SSE and renders a native calendar picker showing bookable dates:
text
Customer clicks "August 20, 2026"
│
▼
POST /api/sessions/:id/input { "selectedDate": "2026-08-20" }2. Direct Voice (Gemini Live Audio)
The Voice Channel Projector transforms the boundary into natural spoken context, while the Agent (boulevardBookingAgent) defines the exact dynamic tool:
spokenPrompt: "Which date works best for your appointment?"tools:submit_input({ selectedDate: string })
text
Customer speaks: "I'd like to come in this Thursday, August 20th"
│
▼
Gemini Live calls: submit_input({ "selectedDate": "2026-08-20" })
│
▼
POST /api/live/session/:id/execute ──► agent.handleAction()3. Conversational AI Chat (app.agent())
The multi-turn chat agent receives the user message:
text
Customer types: "Thursday works great for me"
│
▼
Agent resolves intent → calls submit_input({ "selectedDate": "2026-08-20" })The Convergence: Schema vs. Fresh-State Validation
All three channels submit their proposed input to the Runtime Boundary. Invariant differentiates between structural validity and execution validity:
text
Gemini proposes: { selectedDate: "2026-09-99" }
│
▼
Schema Validation
✕
Malformed date
│
▼
REJECT
(Workflow unchanged)
Gemini proposes: { selectedDate: "2026-08-23" }
│
▼
Schema Validation
✓
│
▼
Fresh-State / Domain Check
✕
Date no longer bookable
│
▼
REJECT
(Workflow unchanged)A proposal can be structurally valid and still be invalid for the current execution state. In both cases, rejection means no workflow transition is committed.
Model mistakes do not become execution history. The workflow remains safely waiting at select-date-ui until valid input is submitted.
Reasoning may be probabilistic. Execution progress is not.
🏛️ Who Owns What?
Invariant establishes a strict separation of concerns across 5 distinct architectural layers:
Workflow = Business logic.
Capabilities = External I/O.
Projectors = Channel representation.
Routes = Transport & delivery.
Runtime = Execution guarantees.
This separation makes channels replaceable. Gemini Live can be replaced, the web widget can be redesigned, or a new MCP interface can be added without redefining the booking workflow. Channels change how users interact with the process—not what the process means.
📊 Complete System Architecture
Notice how Projection ("What should this channel see?") is strictly separated from Validation ("May this proposed action become execution?"):
🗺️ The Map & The Zoom: 8-Phase Master Workflow
The master workflow (examples/boulevard-booking/src/workflows/booking.ts in this repository) serves as The Map of the entire business. Complex sub-domains live in isolated fragments (The Zoom):
ts
export const boulevardBookingWorkflow = app.workflow<BookingWorkflowInput>('boulevard-booking', {
description: 'Durable Boulevard salon appointment booking workflow',
version: '3.0.0',
})
// ---------------------------------------------------------------------------
// PHASE 1 — LOCATION & CART
// Resolve booking location, then initialize Boulevard cart.
// ---------------------------------------------------------------------------
.branch('check-location-provided', ({ input }) => (input.locationId ? 'PROVIDED' : 'PROMPT'), {
PROVIDED: app.fragment('use-loc').step('set-loc', ({ input }) => ({ locationId: input.locationId ?? 'loc_soho' })),
PROMPT: app.fragment('ask-loc').capability('list-locs', listLocationsCapability).wait('select-loc-ui', {
schema: SelectLocationSchema,
presentation: { type: 'selection', label: 'Salon Location', prompt: 'Which studio would you like to visit?' },
}),
})
.capability('init-cart', createCartCapability)
// ---------------------------------------------------------------------------
// PHASE 2 — SERVICE SELECTION (Guided Haircut Tree vs Catalog)
// ---------------------------------------------------------------------------
.step('filter-service-categories', ({ state }) => ({
availableCategories: state.cart.availableCategories.filter((c: ServiceCategory) => c.categoryType === 'SERVICE'),
}))
.wait('select-category-ui', {
schema: SelectCategorySchema,
presentation: { type: 'selection', label: 'Service Category', prompt: 'Which category of service are you looking for?' },
})
.branch<{ serviceSelection: ServiceSelection }>(
'service-selection-path',
({ state }) => (state.categoryName === 'Grooming' ? 'GUIDED_GROOMING' : 'STANDARD_SERVICE'),
{
GUIDED_GROOMING: haircutGuidedFragment, // The Zoom: length -> shampoo -> custom recommendation
STANDARD_SERVICE: standardServiceFragment, // The Zoom: direct category picker
}
)
// ---------------------------------------------------------------------------
// PHASE 3 — STAFF SPECIALIST
// ---------------------------------------------------------------------------
.wait('select-staff-ui', {
schema: SelectStaffSchema,
presentation: { type: 'selection', label: 'Select Stylist', prompt: 'Do you have a preferred stylist?' },
})
.capability('add-service-to-cart', addServiceToCartCapability)
// ---------------------------------------------------------------------------
// PHASE 4 — ADD-ONS & UPSELL
// ---------------------------------------------------------------------------
.capability('refresh-cart-for-addons', getCartCapability)
.wait('select-addons-ui', {
schema: SelectAddonsSchema,
presentation: { type: 'selection', label: 'Enhance Your Visit', prompt: 'Would you like to add an Olaplex mask?' },
})
.capability('add-addons-to-cart', addAddonsToCartCapability)
// ---------------------------------------------------------------------------
// PHASE 5 — CLIENT CONTACT & IDENTITY
// ---------------------------------------------------------------------------
.wait('client-contact-ui', {
schema: ClientContactSchema,
presentation: { type: 'form', label: 'Contact Information', prompt: 'Please enter your contact details.' },
})
.branch('client-identity-path', ({ input }) => (input.userUid ? 'AUTHENTICATED' : 'GUEST'), {
AUTHENTICATED: authenticatedClientFragment,
GUEST: guestClientFragment,
})
// ---------------------------------------------------------------------------
// PHASE 6 — SCHEDULING & TIME HOLD
// ---------------------------------------------------------------------------
.capability('get-bookable-dates', getBookableDatesCapability)
.wait('select-date-ui', {
schema: SelectDateSchema,
presentation: { type: 'date', label: 'Appointment Date', prompt: 'Which day works best for you?' },
})
.capability('get-available-times', getAvailableTimesCapability)
.wait('select-time-ui', {
schema: SelectTimeSlotSchema,
presentation: { type: 'time', label: 'Appointment Time', prompt: 'What time would you prefer?' },
})
.capability('reserve-time-slot', reserveTimeSlotCapability)
// ---------------------------------------------------------------------------
// PHASE 7 — SUMMARY REVIEW & PAYMENT AUTHORIZATION
// ---------------------------------------------------------------------------
.capability('refresh-cart-summary', getCartCapability)
.wait('review-booking-summary-ui', {
schema: SummaryConfirmSchema,
presentation: { type: 'confirmation', label: 'Review Booking Summary', prompt: 'Please confirm your appointment details.' },
})
.branch('payment-requirement-path', ({ state }) => (state.cart.summary.paymentMethodRequired ? 'REQUIRED' : 'NOT_REQUIRED'), {
REQUIRED: paymentFlowFragment,
NOT_REQUIRED: app.fragment('skip-payment').step('acknowledge-no-payment', () => ({ paymentApplied: false })),
})
// ---------------------------------------------------------------------------
// PHASE 8 — TRANSACTIONAL OUTBOX CHECKOUT
// Stable Invariant command identity is persisted in the outbox.
// ---------------------------------------------------------------------------
.capability('checkout-appointment', {
idempotencyKey: "blvd-checkout:{{runId}}",
handler: checkoutAppointmentCapability,
})
.step('complete-booking', ({ state }) => ({
terminalNotice: {
label: 'Booking Confirmed',
prompt: `Boulevard confirmed appointment ${state.appointment.appointmentId}.`,
tone: 'success',
},
}));📡 Connecting External Clients
1. Interactive UI (ai-agents/chat-widget)
bash
# Streams live state_change SSE snapshots
curl -N http://localhost:8080/api/sessions/sess_100/stream2. Direct Gemini Live Audio
bash
# 1. Fetch ephemeral Gemini Live session token
curl -X POST http://localhost:8080/api/live/session/sess_100/token
# 2. Fetch active voice projection & dynamic tool definition
curl http://localhost:8080/api/live/session/sess_100/context3. Conversational AI Chat Agent
bash
curl -X POST http://localhost:3000/conversations/session/sess_100/messages \
-H "Content-Type: application/json" \
-d '{"message": "I want to book a signature haircut in Soho this Thursday with Elena"}'🧪 Comprehensive Conformance Tests
Run the full end-to-end test suite:
bash
pnpm --filter example-boulevard-booking testtext
Test Files 14 passed (14)
Tests 133 passed (133)
✓ test/voice-projector.test.ts # Bounded action space & natural speech
✓ test/appointment-management.test.ts # Appointment cancel & reschedule flows
✓ test/workflow-conformance.test.ts # 8-phase booking execution & outbox
✓ test/projection-stream-sse.test.ts # SSE streamCursor & Last-Event-ID replay
✓ test/app.test.ts # Fastify integration & session snapshots
✓ test/live-voice.test.ts # Gemini Live token & fresh-state execution
✓ test/booking-provider-contract.test.ts # Client lookup, saved methods & checkout contracts
✓ test/payment-security.test.ts # Payment ingress, OCC & zero token persistence
✓ test/booking-summary.test.ts # Stable, human-readable date/time projection
✓ test/contact-collection.test.ts # Multi-turn contact collection
✓ test/date-selection-loop.test.ts # Date search and selection loop
✓ test/demo-routes.test.ts # Public demo transport behavior
✓ test/sqlite-persistence.test.ts # Durable SQLite rehydration
✓ test/identity-security.test.ts # OTP ingress and zero raw-code persistenceFreeze gate
Run the example typecheck, complete test suite, web build, documentation audit, and documentation build from one unchanged commit:
bash
pnpm --filter example-boulevard-booking typecheck
pnpm --filter example-boulevard-booking test
pnpm --filter example-boulevard-booking build
pnpm docs:check
pnpm docs:buildThese tests use deterministic Boulevard transport doubles and do not create an external appointment. A live sandbox smoke is a separate, explicit operator action. GET /api/health must report the intended storage, inventory, payment, checkout, and createsRealAppointments modes before that smoke.