Build a booking app with Claude Code
In this tutorial you build Stillwater, a booking app for a yoga and pilates studio: members book classes and see only their own bookings, a full class can't be booked, instructors get a live desk with a chart, garden classes show the weather, an AI suggests a class, and people sign in with Google. Every file and screenshot below comes from the real app, live at example-bookings.apps.appbricx.com.

Try it first. Sign in at example-bookings.apps.appbricx.com as a member (ana@stillwater.test / falcon-iris-ivory-64) or an instructor (ines@stillwater.test / autumn-coral-lemon-98). The data resets every night. All the files are in appbricx-examples/studio-bookings.
What you need
- An Appbricx account (free).
- Claude Code. Any MCP client works the same way.
- About 30 minutes.
1. Connect Claude Code
In your project folder, add the Appbricx MCP server:
claude mcp add --transport http appbricx https://appbricx.com/api/mcpOr commit this .mcp.json so everyone on the project gets it:
{
"mcpServers": {
"appbricx": {
"type": "http",
"url": "https://appbricx.com/api/mcp"
}
}
}Start claude, run /mcp, pick appbricx and choose Authenticate. A browser window opens: sign in, pick your workspace, leave read, apply and deploy ticked, and click Authorize. No token to copy. You can revoke it later in Workspace settings, Coding agents.
2. The prompt
Use appbricx. Create a project "Studio Bookings" for Stillwater, a yoga and
pilates studio in Lisbon (brand colour #2F7D6D, calm and airy).
- Members sign up (also with Google), see this week's classes by day, book a
mat in one tap and cancel. They see only their own bookings and profile.
- Instructors manage classes, teachers and members, see today's rosters,
check people in or mark no-shows, and see a chart of mats booked this week.
- Never let a full class or a class that has started be booked, whoever calls.
- When a booking is made, count it on the class and notify instructors. Free
the mat when a booking is cancelled. Send instructors a digest at 07:00 Lisbon.
- Garden classes get a "check the forecast" link that calls Open-Meteo on the
server. Add an AI "Not sure what to take?" box that suggests one class.
- Seed 3 teachers, 6 members, 14 classes over the next week and some bookings.
Write custom screens for the member schedule, "my classes" and the instructor
desk. Check, apply, screenshot every screen as each role, verify until it
passes, then deploy with the subdomain example-bookings.Claude reads the guide (get_guide), creates the project (create_project) and writes the contract. The rest of this page walks through what it wrote and what each step returned.
3. The contract
This is the complete contract that was applied. The three component sources and the server function body are shown in the next sections; in app.json they are inlined. Things to notice:
personas: members sign up (consumer), instructors run the studio (staff).accessonmemberandbooking: members read and change only their own rows.actor_refonbooking: the server fillsmember_id, so nobody books as someone else.unique: no booking the same class twice.rules: no booking a full or started class.auth.google: Sign in with Google, no Google Cloud setup.- Four
workflows: two on booking changes, a morning schedule, and theclass-forecastserver function.
{
"app": {
"name": "Stillwater",
"tagline": "Yoga and pilates in Lisbon, booked in two taps",
"brand_icon": "Sun",
"theme": "sage-cream",
"nav_style": "topbar",
"density": "comfortable",
"brand_color": "#2F7D6D",
"direction": "calm-health"
},
"auth": {
"google": true
},
"personas": [
{
"id": "member",
"label": "Member",
"description": "Books classes and manages their own bookings",
"kind": "consumer"
},
{
"id": "instructor",
"label": "Instructor",
"description": "Runs the schedule, rosters and members",
"kind": "staff"
}
],
"coordinates": {
"primaryVerb": "book",
"inventoryShape": "shelf",
"moneySurface": "per-slot"
},
"entities": [
{
"name": "member",
"label_field": "name",
"fields": [
{
"name": "name",
"type": "text"
},
{
"name": "email",
"type": "email",
"optional": true
},
{
"name": "goals",
"type": "text",
"optional": true
}
],
"access": {
"member": {
"read": "own",
"update": "own"
}
}
},
{
"name": "instructor",
"label_field": "name",
"fields": [
{
"name": "name",
"type": "text"
},
{
"name": "bio",
"type": "text",
"optional": true
},
{
"name": "photo",
"type": "image",
"optional": true
}
]
},
{
"name": "session",
"label_field": "title",
"fields": [
{
"name": "title",
"type": "text"
},
{
"name": "style",
"type": "enum",
"enum_values": [
"vinyasa",
"pilates",
"yin",
"breathwork"
]
},
{
"name": "level",
"type": "enum",
"enum_values": [
"all levels",
"beginner",
"intermediate"
]
},
{
"name": "instructor_id",
"type": "ref",
"ref": "instructor"
},
{
"name": "starts_at",
"type": "timestamp"
},
{
"name": "duration_min",
"type": "number"
},
{
"name": "capacity",
"type": "number"
},
{
"name": "booked",
"type": "number",
"optional": true
},
{
"name": "room",
"type": "enum",
"enum_values": [
"loft",
"garden"
]
},
{
"name": "outdoor",
"type": "boolean",
"optional": true
},
{
"name": "description",
"type": "text",
"optional": true
}
],
"rules": [
{
"id": "sensible-class",
"on": "both",
"message": "Classes run 15 to 180 minutes for 1 to 30 people.",
"check": "const d = Number(row.duration_min); const c = Number(row.capacity); return d >= 15 && d <= 180 && c >= 1 && c <= 30;"
}
]
},
{
"name": "booking",
"label_field": "status",
"fields": [
{
"name": "member_id",
"type": "ref",
"ref": "member"
},
{
"name": "session_id",
"type": "ref",
"ref": "session"
},
{
"name": "status",
"type": "enum",
"enum_values": [
"booked",
"attended",
"no_show",
"cancelled"
]
},
{
"name": "note",
"type": "text",
"optional": true
}
],
"unique": [
[
"member_id",
"session_id"
]
],
"actor_ref": "member_id",
"owner_refs": [
"member_id"
],
"access": {
"member": {
"read": "own",
"create": true,
"update": "own",
"delete": "none"
}
},
"rules": [
{
"id": "seats-left",
"on": "create",
"message": "That class is full. Try another time.",
"check": "const r = await ctx.db.query('select capacity, booked, starts_at from session where id = $1', [row.session_id]);\nconst rows = Array.isArray(r) ? r : (r as { rows?: Array<Record<string, unknown>> }).rows ?? [];\nconst s = rows[0];\nif (!s) return 'That class is no longer on the schedule.';\nif (new Date(String(s.starts_at)).getTime() < ctx.now().getTime()) return 'That class has already started.';\nreturn Number(s.booked ?? 0) < Number(s.capacity);"
}
]
}
],
"seeds": {
"instructor": [
{
"id": "i1",
"name": "Inês Duarte",
"bio": "Vinyasa and sunrise flows. Teaching since 2014.",
"photo": "yoga teacher smiling in sunlit studio"
},
{
"id": "i2",
"name": "Tomás Reis",
"bio": "Pilates and mobility for desk-bound backs.",
"photo": "pilates instructor on reformer machine"
},
{
"id": "i3",
"name": "Maya Lindqvist",
"bio": "Yin, breathwork and slow evenings.",
"photo": "woman meditating on wooden floor"
}
],
"member": [
{
"id": "m1",
"name": "Ana Ribeiro",
"email": "ana@example.com",
"goals": "Ease lower back stiffness, two classes a week"
},
{
"id": "m2",
"name": "Joe Carter",
"email": "joe@example.com",
"goals": "Build core strength for running"
},
{
"id": "m3",
"name": "Sofia Marques",
"email": "sofia@example.com",
"goals": "Sleep better"
},
{
"id": "m4",
"name": "Daniel Okafor",
"email": "daniel@example.com",
"goals": "First time on a mat"
},
{
"id": "m5",
"name": "Leonor Costa",
"email": "leonor@example.com",
"goals": "Keep a morning routine"
},
{
"id": "m6",
"name": "Ravi Menon",
"email": "ravi@example.com",
"goals": "Flexibility for climbing"
}
],
"session": [
{
"id": "s1",
"title": "Sunrise Flow",
"style": "vinyasa",
"level": "all levels",
"instructor_id": "i1",
"starts_at": "2026-10-13T06:30:00Z",
"duration_min": 60,
"capacity": 14,
"booked": 3,
"room": "garden",
"outdoor": true,
"description": "Breath-led flow that builds heat and strength. Mats and blocks provided."
},
{
"id": "s2",
"title": "Desk Reset Pilates",
"style": "pilates",
"level": "beginner",
"instructor_id": "i2",
"starts_at": "2026-10-13T17:30:00Z",
"duration_min": 45,
"capacity": 10,
"booked": 1,
"room": "loft",
"outdoor": false,
"description": "Slow, precise mat work for core, posture and a happier back."
},
{
"id": "s3",
"title": "Lunchtime Pilates",
"style": "pilates",
"level": "all levels",
"instructor_id": "i2",
"starts_at": "2026-10-14T11:30:00Z",
"duration_min": 45,
"capacity": 10,
"booked": 0,
"room": "loft",
"outdoor": false,
"description": "Slow, precise mat work for core, posture and a happier back."
},
{
"id": "s4",
"title": "Yin and Breath",
"style": "yin",
"level": "all levels",
"instructor_id": "i3",
"starts_at": "2026-10-14T18:00:00Z",
"duration_min": 75,
"capacity": 12,
"booked": 2,
"room": "loft",
"outdoor": false,
"description": "Long, quiet holds to release hips, spine and a busy mind."
},
{
"id": "s5",
"title": "Sunrise Flow",
"style": "vinyasa",
"level": "all levels",
"instructor_id": "i1",
"starts_at": "2026-10-15T06:30:00Z",
"duration_min": 60,
"capacity": 14,
"booked": 1,
"room": "garden",
"outdoor": true,
"description": "Breath-led flow that builds heat and strength. Mats and blocks provided."
},
{
"id": "s6",
"title": "Strong Vinyasa",
"style": "vinyasa",
"level": "intermediate",
"instructor_id": "i1",
"starts_at": "2026-10-15T17:30:00Z",
"duration_min": 60,
"capacity": 12,
"booked": 1,
"room": "loft",
"outdoor": false,
"description": "Breath-led flow that builds heat and strength. Mats and blocks provided."
},
{
"id": "s7",
"title": "Core Pilates",
"style": "pilates",
"level": "intermediate",
"instructor_id": "i2",
"starts_at": "2026-10-16T11:30:00Z",
"duration_min": 45,
"capacity": 10,
"booked": 1,
"room": "loft",
"outdoor": false,
"description": "Slow, precise mat work for core, posture and a happier back."
},
{
"id": "s8",
"title": "Breathwork for Sleep",
"style": "breathwork",
"level": "all levels",
"instructor_id": "i3",
"starts_at": "2026-10-16T19:00:00Z",
"duration_min": 40,
"capacity": 12,
"booked": 1,
"room": "loft",
"outdoor": false,
"description": "Guided breathing to settle the nervous system. No experience needed."
},
{
"id": "s9",
"title": "Sunrise Flow",
"style": "vinyasa",
"level": "all levels",
"instructor_id": "i1",
"starts_at": "2026-10-17T06:30:00Z",
"duration_min": 60,
"capacity": 14,
"booked": 0,
"room": "garden",
"outdoor": true,
"description": "Breath-led flow that builds heat and strength. Mats and blocks provided."
},
{
"id": "s10",
"title": "Slow Flow",
"style": "yin",
"level": "beginner",
"instructor_id": "i3",
"starts_at": "2026-10-17T17:00:00Z",
"duration_min": 60,
"capacity": 12,
"booked": 1,
"room": "loft",
"outdoor": false,
"description": "Long, quiet holds to release hips, spine and a busy mind."
},
{
"id": "s11",
"title": "Garden Vinyasa",
"style": "vinyasa",
"level": "all levels",
"instructor_id": "i1",
"starts_at": "2026-10-18T08:30:00Z",
"duration_min": 75,
"capacity": 16,
"booked": 2,
"room": "garden",
"outdoor": true,
"description": "Breath-led flow that builds heat and strength. Mats and blocks provided."
},
{
"id": "s12",
"title": "Mat Pilates",
"style": "pilates",
"level": "beginner",
"instructor_id": "i2",
"starts_at": "2026-10-18T10:30:00Z",
"duration_min": 50,
"capacity": 10,
"booked": 1,
"room": "loft",
"outdoor": false,
"description": "Slow, precise mat work for core, posture and a happier back."
},
{
"id": "s13",
"title": "Long Yin",
"style": "yin",
"level": "all levels",
"instructor_id": "i3",
"starts_at": "2026-10-19T09:00:00Z",
"duration_min": 75,
"capacity": 12,
"booked": 1,
"room": "loft",
"outdoor": false,
"description": "Long, quiet holds to release hips, spine and a busy mind."
},
{
"id": "s14",
"title": "Breathwork Circle",
"style": "breathwork",
"level": "all levels",
"instructor_id": "i3",
"starts_at": "2026-10-19T16:30:00Z",
"duration_min": 45,
"capacity": 14,
"booked": 0,
"room": "garden",
"outdoor": true,
"description": "Guided breathing to settle the nervous system. No experience needed."
}
],
"booking": [
{
"id": "b1",
"member_id": "m1",
"session_id": "s1",
"status": "booked"
},
{
"id": "b2",
"member_id": "m1",
"session_id": "s4",
"status": "booked"
},
{
"id": "b3",
"member_id": "m1",
"session_id": "s10",
"status": "booked"
},
{
"id": "b4",
"member_id": "m2",
"session_id": "s1",
"status": "booked"
},
{
"id": "b5",
"member_id": "m2",
"session_id": "s7",
"status": "booked"
},
{
"id": "b6",
"member_id": "m3",
"session_id": "s4",
"status": "booked"
},
{
"id": "b7",
"member_id": "m3",
"session_id": "s8",
"status": "booked"
},
{
"id": "b8",
"member_id": "m4",
"session_id": "s2",
"status": "booked"
},
{
"id": "b9",
"member_id": "m4",
"session_id": "s12",
"status": "booked"
},
{
"id": "b10",
"member_id": "m5",
"session_id": "s1",
"status": "booked"
},
{
"id": "b11",
"member_id": "m5",
"session_id": "s5",
"status": "booked"
},
{
"id": "b12",
"member_id": "m6",
"session_id": "s6",
"status": "booked"
},
{
"id": "b13",
"member_id": "m6",
"session_id": "s11",
"status": "booked"
},
{
"id": "b14",
"member_id": "m2",
"session_id": "s11",
"status": "booked"
},
{
"id": "b15",
"member_id": "m3",
"session_id": "s13",
"status": "booked"
}
]
},
"pages": [
{
"name": "schedule",
"route": "/schedule",
"title": "Schedule",
"icon": "CalendarDays",
"personas": [
"member"
],
"capabilities": {
"create": false,
"edit": false,
"delete": false
},
"layout": {
"kind": "custom",
"custom": "ClassSchedule",
"props": {
"title": "This week at Stillwater"
}
}
},
{
"name": "my-classes",
"route": "/my-classes",
"title": "My classes",
"icon": "Bookmark",
"personas": [
"member"
],
"capabilities": {
"create": false,
"edit": false,
"delete": false
},
"layout": {
"kind": "custom",
"custom": "MyBookings",
"props": {
"title": "Your classes"
}
}
},
{
"name": "desk",
"route": "/desk",
"title": "Desk",
"icon": "LayoutDashboard",
"personas": [
"instructor"
],
"capabilities": {
"create": false,
"edit": false,
"delete": false
},
"layout": {
"kind": "custom",
"custom": "StudioDesk",
"props": {
"title": "Studio desk"
}
}
},
{
"name": "classes",
"route": "/classes",
"title": "Classes",
"icon": "Calendar",
"personas": [
"instructor"
],
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Classes",
"subtitle": "The timetable members book from."
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "session",
"presentation": "table",
"columns": [
"title",
"starts_at",
"instructor_id",
"level",
"room",
"booked",
"capacity"
],
"searchField": "title",
"filters": [
{
"field": "style",
"label": "Style"
},
{
"field": "instructor_id",
"label": "Teacher"
}
],
"emptyCopy": "No classes yet. Add the first one and members can book it straight away."
}
}
]
},
"form": {
"kind": "block",
"block": "EntityForm",
"props": {
"entity": "session",
"mode": "both",
"layout": "dialog",
"fields": [
"title",
"style",
"level",
"instructor_id",
"starts_at",
"duration_min",
"capacity",
"room",
"outdoor",
"description"
],
"submitLabel": "Save class",
"successCopy": "Class saved. It's on the schedule now."
}
}
},
{
"name": "bookings",
"route": "/bookings",
"title": "Bookings",
"icon": "ListChecks",
"personas": [
"instructor"
],
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Bookings",
"subtitle": "Every booking, by status. Add one for a walk-in."
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "booking",
"presentation": "grouped",
"groupBy": "status",
"columns": [
"member_id",
"session_id",
"status",
"note"
],
"filters": [
{
"field": "session_id",
"label": "Class"
}
],
"emptyCopy": "No bookings yet. They appear here the moment a member books."
}
}
]
},
"form": {
"kind": "block",
"block": "EntityForm",
"props": {
"entity": "booking",
"mode": "create",
"layout": "dialog",
"fields": [
"member_id",
"session_id",
"note"
],
"submitLabel": "Book them in"
}
}
},
{
"name": "members",
"route": "/members",
"title": "Members",
"icon": "Users",
"personas": [
"instructor"
],
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Members",
"subtitle": "Everyone who has signed up."
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "member",
"presentation": "rows",
"columns": [
"name",
"email",
"goals"
],
"searchField": "name",
"emptyCopy": "No members yet. Share your link and they will appear here."
}
}
]
},
"form": {
"kind": "block",
"block": "EntityForm",
"props": {
"entity": "member",
"mode": "both",
"layout": "dialog",
"fields": [
"name",
"email",
"goals"
],
"submitLabel": "Save member"
}
}
},
{
"name": "teachers",
"route": "/teachers",
"title": "Teachers",
"icon": "Award",
"personas": [
"instructor"
],
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Teachers",
"subtitle": "Who leads each class. Members see these names and photos."
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "instructor",
"presentation": "cards",
"columns": [
"photo",
"name",
"bio"
],
"emptyCopy": "Add yourself first, then the people who teach with you."
}
}
]
},
"form": {
"kind": "block",
"block": "EntityForm",
"props": {
"entity": "instructor",
"mode": "both",
"layout": "dialog",
"fields": [
"name",
"bio",
"photo"
],
"submitLabel": "Save teacher"
}
}
},
{
"name": "team",
"route": "/team",
"title": "Team",
"icon": "UserPlus",
"personas": [
"instructor"
],
"capabilities": {
"create": false,
"edit": false,
"delete": false
},
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "Team and access",
"subtitle": "Invite another instructor."
},
{
"kind": "block",
"block": "TeamAdmin",
"props": {
"inviteLabel": "Invite an instructor",
"emptyCopy": "It's just you for now. Invite the people who teach with you."
}
}
]
}
}
],
"components": [
{
"name": "ClassSchedule",
"kind": "code",
"purpose": "Member schedule: pick a day, book a class, AI class finder, garden-class forecast",
"props": {
"title": {
"type": "text"
}
},
"sample": {
"title": "This week at Stillwater"
},
"fallback": {
"EmptyState": {
"title": "The schedule is taking a breather",
"description": "Refresh the page in a moment."
}
},
"source": "<components/ClassSchedule.tsx, below>"
},
{
"name": "MyBookings",
"kind": "code",
"purpose": "A member's upcoming classes with cancel, and their history",
"props": {
"title": {
"type": "text"
}
},
"sample": {
"title": "Your classes"
},
"fallback": {
"EmptyState": {
"title": "Your classes will be back in a moment",
"description": "Refresh the page to try again."
}
},
"source": "<components/MyBookings.tsx, below>"
},
{
"name": "StudioDesk",
"kind": "code",
"purpose": "Instructor desk: next day's rosters with check-in, and a chart of mats booked",
"props": {
"title": {
"type": "text"
}
},
"sample": {
"title": "Studio desk"
},
"fallback": {
"EmptyState": {
"title": "The desk is reloading",
"description": "Refresh the page to try again."
}
},
"source": "<components/StudioDesk.tsx, below>"
}
],
"workflows": [
{
"id": "count-booking",
"title": "Count the mat and tell instructors",
"on": {
"entity": "booking",
"event": "created"
},
"do": [
{
"action": "custom",
"code": "const s = await ctx.db.get('session', String(row.session_id));\nif (!s) return;\nconst booked = Number(s.booked ?? 0) + 1;\nawait ctx.db.update('session', String(s.id), { booked });\nctx.notify({ title: 'New booking', body: `${String(s.title)}: ${booked} of ${Number(s.capacity)} mats booked`, role: 'instructor' });"
}
]
},
{
"id": "free-mat-on-cancel",
"title": "Free the mat when a booking is cancelled",
"on": {
"entity": "booking",
"event": "updated"
},
"when": {
"field": "status",
"equals": "cancelled"
},
"do": [
{
"action": "custom",
"code": "const s = await ctx.db.get('session', String(row.session_id));\nif (!s) return;\nawait ctx.db.update('session', String(s.id), { booked: Math.max(0, Number(s.booked ?? 0) - 1) });"
}
]
},
{
"id": "morning-digest",
"title": "Morning bookings digest",
"on": {
"schedule": "daily 07:00",
"entity": "booking",
"tz": "Europe/Lisbon"
},
"do": [
{
"action": "notify",
"role": "instructor",
"title": "Good morning",
"body": "{{count}} bookings in the last 24 hours.\n{{by:status}}"
}
]
},
{
"id": "class-forecast",
"title": "Weather for a garden class",
"on": {
"call": true,
"roles": [
"member",
"instructor"
]
},
"do": [
{
"action": "custom",
"code": "<functions/class-forecast.ts, below>"
}
]
}
]
}Claude checked it first. Nothing changes on a check:
check_contract →
{
"ok": true,
"errors": [],
"fixes": ["3 custom component(s) passed every gate: ClassSchedule, MyBookings, StudioDesk"],
"warnings": []
}The first check did report fixes: three tables had no page that could create their rows, so it added management pages, and the components had no fallback. Claude added the Bookings, Members and Teachers pages and a fallback per component so the contract says what it means.
4. The custom screens
Each screen is one React component. Before putting them in the contract, Claude ran each through check_component, which answered { "ok": true, "passed": ["static", "types", "compile", "render"] }.
ClassSchedule.tsx: the member schedule
Day chips, one-tap booking, the AI class finder and the forecast link. It reads with app.rows, follows changes with app.live, writes with app.create, and shows the rule's message if a booking is refused.
import { useEffect, useMemo, useState } from "react";
import { motion } from "motion/react";
import { Clock, Sun, Sparkles, Users, Check } from "lucide-react";
import { Markdown } from "@app/kit/primitives";
import type { AppHandle } from "@app/kit/primitives";
type ClassScheduleProps = { title?: string; app?: AppHandle };
type Forecast = { tempC: number | null; rainChance: number | null; summary: string };
const TZ = "Europe/Lisbon";
const FALLBACK = "Our class finder is resting right now. **Long Yin** or **Breathwork for Sleep** are good places to start.";
const str = (v: unknown) => (v == null ? "" : String(v));
const num = (v: unknown) => (typeof v === "number" ? v : Number(v) || 0);
const dayKey = (iso: string) =>
new Intl.DateTimeFormat("en-CA", { timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(iso));
const dayLabel = (iso: string) =>
new Intl.DateTimeFormat("en-GB", { timeZone: TZ, weekday: "short", day: "numeric" }).format(new Date(iso));
const timeLabel = (iso: string) =>
new Intl.DateTimeFormat("en-GB", { timeZone: TZ, hour: "2-digit", minute: "2-digit" }).format(new Date(iso));
export default function ClassSchedule({ title = "This week at Stillwater", app }: ClassScheduleProps) {
const [now, setNow] = useState(0);
const [day, setDay] = useState("");
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [forecasts, setForecasts] = useState<Record<string, Forecast | string>>({});
const [feeling, setFeeling] = useState("");
const [advice, setAdvice] = useState("");
const [thinking, setThinking] = useState(false);
useEffect(() => setNow(Date.now()), []);
useEffect(() => (app ? app.live("session") : undefined), [app]);
const sessions = app?.rows("session") ?? [];
const instructors = app?.rows("instructor") ?? [];
const bookings = app?.rows("booking") ?? [];
const meId = app?.me("member");
const upcoming = useMemo(
() =>
sessions
.filter((s) => new Date(str(s.starts_at)).getTime() > now)
.sort((a, b) => str(a.starts_at).localeCompare(str(b.starts_at))),
[sessions, now],
);
const days = useMemo(() => {
const seen = new Map<string, string>();
for (const s of upcoming) seen.set(dayKey(str(s.starts_at)), dayLabel(str(s.starts_at)));
return [...seen.entries()].slice(0, 7);
}, [upcoming]);
const activeDay = day || (days[0]?.[0] ?? "");
const shown = upcoming.filter((s) => dayKey(str(s.starts_at)) === activeDay);
const teacher = (id: unknown) => str(instructors.find((i) => str(i.id) === str(id))?.name);
const myBooking = (sessionId: unknown) =>
bookings.find((b) => str(b.session_id) === str(sessionId) && str(b.status) === "booked");
async function book(sessionId: string) {
if (!app) return;
setBusy(sessionId);
setError("");
try {
await app.create("booking", { session_id: sessionId, member_id: meId, status: "booked" });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy("");
}
}
async function forecast(sessionId: string) {
if (!app) return;
setForecasts((f) => ({ ...f, [sessionId]: "Checking the sky..." }));
try {
const f = await app.call<Forecast>("class-forecast", { session_id: sessionId });
setForecasts((all) => ({ ...all, [sessionId]: f }));
} catch (e) {
setForecasts((all) => ({ ...all, [sessionId]: e instanceof Error ? e.message : String(e) }));
}
}
async function suggest() {
if (!app || !feeling.trim()) return;
setThinking(true);
setAdvice("");
const list = upcoming
.slice(0, 20)
.map((s) => `- ${str(s.title)} (${str(s.style)}, ${str(s.level)}, ${num(s.duration_min)} min) on ${dayLabel(str(s.starts_at))} at ${timeLabel(str(s.starts_at))}`)
.join("\n");
try {
const answer = await app.ai.ask(`How I feel today: ${feeling}\n\nClasses this week:\n${list}`, {
system: "You are the front desk at a small yoga and pilates studio. Recommend ONE class from the list and say why in two short sentences. Use markdown bold for the class name. Never invent classes.",
maxTokens: 600,
});
setAdvice(answer.trim() || FALLBACK);
} catch {
setAdvice(FALLBACK);
} finally {
setThinking(false);
}
}
return (
<div className="mx-auto max-w-3xl space-y-6 px-4 py-6">
<header className="space-y-1">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">{title}</h1>
<p className="text-muted-foreground">Pick a day, book a mat. Cancel up to the start of class.</p>
</header>
<section className="rounded-2xl border border-border bg-card p-4">
<form className="space-y-3" onSubmit={(e) => { e.preventDefault(); void suggest(); }}>
<label htmlFor="feeling" className="flex items-center gap-2 text-sm font-medium text-foreground">
<Sparkles className="h-4 w-4 text-primary" /> Not sure what to take?
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<input
id="feeling"
value={feeling}
onChange={(e) => setFeeling(e.target.value)}
placeholder="Stiff back after a long week, 45 minutes free"
className="flex-1 rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground"
/>
<button type="submit" disabled={thinking || !feeling.trim()} className="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50">
{thinking ? "Thinking..." : "Suggest a class"}
</button>
</div>
{advice && <div className="rounded-xl bg-muted p-3 text-sm"><Markdown text={advice} size="sm" /></div>}
</form>
</section>
<nav aria-label="Days" className="flex gap-2 overflow-x-auto pb-1">
{days.map(([key, label]) => (
<button
key={key}
onClick={() => setDay(key)}
className={`shrink-0 rounded-full border px-4 py-1.5 text-sm ${key === activeDay ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground"}`}
>
{label}
</button>
))}
</nav>
{error && <p role="alert" className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
<ul className="space-y-3">
{shown.length === 0 && (
<li className="rounded-2xl border border-dashed border-border p-8 text-center text-muted-foreground">
No classes on the schedule yet. New weeks open every Sunday.
</li>
)}
{shown.map((s) => {
const id = str(s.id);
const left = Math.max(0, num(s.capacity) - num(s.booked));
const mine = myBooking(id);
const f = forecasts[id];
return (
<motion.li key={id} layout initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="rounded-2xl border border-border bg-card p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> {timeLabel(str(s.starts_at))} · {num(s.duration_min)} min · {str(s.room)}
</p>
<h2 className="text-lg font-semibold text-foreground">{str(s.title)}</h2>
<p className="text-sm text-muted-foreground">
with {teacher(s.instructor_id) || "a guest teacher"} · {str(s.level)}
</p>
</div>
{mine ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary">
<Check className="h-4 w-4" /> Booked
</span>
) : (
<button
onClick={() => void book(id)}
disabled={left === 0 || busy === id}
className="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
{left === 0 ? "Full" : busy === id ? "Booking..." : "Book"}
</button>
)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1"><Users className="h-3.5 w-3.5" /> {left} of {num(s.capacity)} mats left</span>
{s.outdoor === true && (
<button onClick={() => void forecast(id)} className="inline-flex items-center gap-1 text-primary underline-offset-2 hover:underline">
<Sun className="h-3.5 w-3.5" /> Garden class: check the forecast
</button>
)}
</div>
{f && (
<p className="mt-2 rounded-xl bg-muted px-3 py-2 text-sm text-foreground">
{typeof f === "string" ? f : `${f.summary}${f.tempC != null ? `, ${Math.round(f.tempC)}°C` : ""}${f.rainChance != null ? `, ${f.rainChance}% chance of rain` : ""}`}
</p>
)}
</motion.li>
);
})}
</ul>
</div>
);
}MyBookings.tsx: my classes
Upcoming classes with Cancel (a lifecycle move with app.transition) and the history.
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { CalendarDays, X } from "lucide-react";
import type { AppHandle } from "@app/kit/primitives";
type MyBookingsProps = { title?: string; app?: AppHandle };
const TZ = "Europe/Lisbon";
const str = (v: unknown) => (v == null ? "" : String(v));
const when = (iso: string) =>
iso
? new Intl.DateTimeFormat("en-GB", { timeZone: TZ, weekday: "long", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(iso))
: "";
export default function MyBookings({ title = "Your classes", app }: MyBookingsProps) {
const [now, setNow] = useState(0);
const [error, setError] = useState("");
useEffect(() => setNow(Date.now()), []);
useEffect(() => (app ? app.live("booking") : undefined), [app]);
const sessions = app?.rows("session") ?? [];
const bookings = app?.rows("booking") ?? [];
const session = (id: unknown) => sessions.find((s) => str(s.id) === str(id));
const rows = bookings
.map((b) => ({ b, s: session(b.session_id) }))
.sort((x, y) => str(x.s?.starts_at).localeCompare(str(y.s?.starts_at)));
const upcoming = rows.filter((r) => str(r.b.status) === "booked" && new Date(str(r.s?.starts_at)).getTime() > now);
const past = rows.filter((r) => !upcoming.includes(r)).reverse();
async function cancel(id: string) {
if (!app) return;
setError("");
try {
await app.transition("booking", id, "status", "cancelled");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}
return (
<div className="mx-auto max-w-2xl space-y-6 px-4 py-6">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">{title}</h1>
{error && <p role="alert" className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">Coming up</h2>
{upcoming.length === 0 && (
<p className="rounded-2xl border border-dashed border-border p-6 text-center text-muted-foreground">
Nothing booked yet. Your next class is one tap away on the schedule.
</p>
)}
<AnimatePresence initial={false}>
{upcoming.map(({ b, s }) => (
<motion.div
key={str(b.id)}
layout
exit={{ opacity: 0, x: 40 }}
className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-card p-4"
>
<div>
<p className="font-semibold text-foreground">{str(s?.title) || "Class"}</p>
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<CalendarDays className="h-3.5 w-3.5" /> {when(str(s?.starts_at))}
</p>
</div>
<button
onClick={() => void cancel(str(b.id))}
className="inline-flex items-center gap-1 rounded-xl border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
<X className="h-4 w-4" /> Cancel
</button>
</motion.div>
))}
</AnimatePresence>
</section>
{past.length > 0 && (
<section className="space-y-2">
<h2 className="text-sm font-medium text-muted-foreground">History</h2>
<ul className="divide-y divide-border rounded-2xl border border-border bg-card">
{past.map(({ b, s }) => (
<li key={str(b.id)} className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-foreground">{str(s?.title) || "Class"} · {when(str(s?.starts_at))}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-muted-foreground">{str(b.status).replace("_", " ")}</span>
</li>
))}
</ul>
</section>
)}
</div>
);
}
StudioDesk.tsx: the instructor desk
A recharts bar chart of mats booked against capacity, and the next day's rosters with check-in. It follows new bookings live.
import { useEffect, useMemo, useState } from "react";
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip } from "recharts";
import { CheckCircle, UserCheck, XCircle } from "lucide-react";
import type { AppHandle } from "@app/kit/primitives";
type StudioDeskProps = { title?: string; app?: AppHandle };
const TZ = "Europe/Lisbon";
const str = (v: unknown) => (v == null ? "" : String(v));
const num = (v: unknown) => (typeof v === "number" ? v : Number(v) || 0);
const dayKey = (iso: string) =>
new Intl.DateTimeFormat("en-CA", { timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(iso));
const dayLabel = (iso: string) =>
new Intl.DateTimeFormat("en-GB", { timeZone: TZ, weekday: "short", day: "numeric" }).format(new Date(iso));
const timeLabel = (iso: string) =>
new Intl.DateTimeFormat("en-GB", { timeZone: TZ, hour: "2-digit", minute: "2-digit" }).format(new Date(iso));
export default function StudioDesk({ title = "Studio desk", app }: StudioDeskProps) {
const [now, setNow] = useState(0);
const [error, setError] = useState("");
useEffect(() => setNow(Date.now()), []);
useEffect(() => (app ? app.live("booking") : undefined), [app]);
const sessions = app?.rows("session") ?? [];
const bookings = app?.rows("booking") ?? [];
const members = app?.rows("member") ?? [];
const memberName = (id: unknown) => str(members.find((m) => str(m.id) === str(id))?.name) || "Member";
const upcoming = useMemo(
() =>
sessions
.filter((s) => new Date(str(s.starts_at)).getTime() + num(s.duration_min) * 60000 > now)
.sort((a, b) => str(a.starts_at).localeCompare(str(b.starts_at))),
[sessions, now],
);
const firstDay = upcoming[0] ? dayKey(str(upcoming[0].starts_at)) : "";
const today = upcoming.filter((s) => dayKey(str(s.starts_at)) === firstDay);
const chart = useMemo(() => {
const byDay = new Map<string, { day: string; booked: number; capacity: number }>();
for (const s of upcoming) {
const k = dayKey(str(s.starts_at));
const row = byDay.get(k) ?? { day: dayLabel(str(s.starts_at)), booked: 0, capacity: 0 };
row.booked += num(s.booked);
row.capacity += num(s.capacity);
byDay.set(k, row);
}
return [...byDay.values()].slice(0, 7);
}, [upcoming]);
async function mark(id: string, to: "attended" | "no_show") {
if (!app) return;
setError("");
try {
await app.transition("booking", id, "status", to);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}
return (
<div className="mx-auto max-w-5xl space-y-6 px-4 py-6">
<header>
<h1 className="text-3xl font-semibold tracking-tight text-foreground">{title}</h1>
<p className="text-muted-foreground">
{firstDay ? `${dayLabel(str(today[0]?.starts_at))}: ${today.length} classes` : "No classes scheduled."}
</p>
</header>
<section className="rounded-2xl border border-border bg-card p-4">
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Mats booked, next 7 days</h2>
<div style={{ height: 220 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chart}>
<XAxis dataKey="day" tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} width={28} tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="capacity" name="Mats" fill="hsl(var(--muted))" radius={4} />
<Bar dataKey="booked" name="Booked" fill="hsl(var(--primary))" radius={4} />
</BarChart>
</ResponsiveContainer>
</div>
</section>
{error && <p role="alert" className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
<div className="grid gap-4 md:grid-cols-2">
{today.length === 0 && (
<p className="rounded-2xl border border-dashed border-border p-6 text-muted-foreground">Add classes from the Classes page.</p>
)}
{today.map((s) => {
const roster = bookings.filter((b) => str(b.session_id) === str(s.id) && str(b.status) !== "cancelled");
return (
<section key={str(s.id)} className="rounded-2xl border border-border bg-card p-4">
<div className="mb-3 flex items-baseline justify-between">
<h3 className="font-semibold text-foreground">{timeLabel(str(s.starts_at))} {str(s.title)}</h3>
<span className="text-sm text-muted-foreground">{num(s.booked)}/{num(s.capacity)}</span>
</div>
{roster.length === 0 && <p className="text-sm text-muted-foreground">No one booked yet.</p>}
<ul className="space-y-2">
{roster.map((b) => (
<li key={str(b.id)} className="flex items-center justify-between gap-2 text-sm">
<span className="min-w-0 truncate text-foreground">{memberName(b.member_id)}</span>
{str(b.status) === "booked" ? (
<span className="flex shrink-0 gap-1 whitespace-nowrap">
<button onClick={() => void mark(str(b.id), "attended")} className="inline-flex items-center gap-1 rounded-lg border border-border px-2 py-1 hover:bg-muted">
<UserCheck className="h-3.5 w-3.5" /> Here
</button>
<button onClick={() => void mark(str(b.id), "no_show")} className="inline-flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-muted-foreground hover:bg-muted">
<XCircle className="h-3.5 w-3.5" /> No-show
</button>
</span>
) : (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<CheckCircle className="h-3.5 w-3.5" /> {str(b.status).replace("_", " ")}
</span>
)}
</li>
))}
</ul>
</section>
);
})}
</div>
</div>
);
}
5. A server function
Components can't call other services, so the forecast runs on the server. It is a workflow with "on": { "call": true, "roles": ["member", "instructor"] } whose body is this file. ClassSchedule calls it with app.call("class-forecast", { session_id }).
// Server function "class-forecast": the body of async (ctx, input, user) => { ... }, TypeScript-checked on apply.
// Called from ClassSchedule with app.call("class-forecast", { session_id }).
// Runs on the server, so it may fetch a third-party API (Open-Meteo, no key).
const id = String(input.session_id ?? "");
if (!id) throw new Error("Which class? Send a session_id.");
const session = await ctx.db.get("session", id);
if (!session) throw new Error("That class isn't on the schedule.");
const starts = new Date(String(session.starts_at));
const hour = starts.toISOString().slice(0, 13) + ":00";
const day = hour.slice(0, 10);
const url = "https://api.open-meteo.com/v1/forecast?latitude=38.7223&longitude=-9.1393"
+ "&hourly=temperature_2m,precipitation_probability,weather_code&timezone=UTC"
+ `&start_date=${day}&end_date=${day}`;
const res = await ctx.http.fetch(url);
if (!res.ok) throw new Error(`The forecast service answered ${res.status}. Try again later.`);
const data = res.json<{ hourly?: { time?: string[]; temperature_2m?: number[]; precipitation_probability?: number[]; weather_code?: number[] } }>();
const h = data?.hourly;
const i = h?.time ? h.time.indexOf(hour) : -1;
if (!h || i < 0) return { tempC: null, rainChance: null, summary: "No forecast yet. Check again closer to the day" };
const code = h.weather_code?.[i] ?? 0;
const summary = code === 0 ? "Clear sky" : code <= 3 ? "Some cloud" : code <= 48 ? "Foggy" : code <= 67 ? "Rain likely" : code <= 77 ? "Snow" : code <= 82 ? "Showers" : "Storms";
ctx.log(`forecast for ${String(session.title)} at ${hour}: ${summary}`);
return { tempC: h.temperature_2m?.[i] ?? null, rainChance: h.precipitation_probability?.[i] ?? null, summary };On the live app it returned:
POST /__appbricx/functions/class-forecast
{"ok":true,"result":{"tempC":16.6,"rainChance":0,"summary":"Some cloud"},"runId":"0432f4d4-…"}Once, Open-Meteo answered 503; the function threw "The forecast service answered 503. Try again later." and the screen showed it. See server functions.
6. An AI feature
The "Not sure what to take?" box sends the member's words and the week's classes to app.ai.ask with a system prompt that says to pick one real class. The key stays on the server and usage is metered to the project. A real answer:
POST /__appbricx/ai/chat
{"ok":true,"content":"I recommend **Desk Reset Pilates** on Tue 13 at 18:30. It's a 45-minute
beginner class after work that targets the kind of stiffness from sitting at a desk.",
"usage":{"prompt_tokens":448,"completion_tokens":224}}A lesson from building it: with maxTokens: 200 one answer came back empty. The component now allows 600 and treats an empty answer like a failure, showing a fallback. See AI in your app.
7. Google sign-in
"auth": { "google": true }in the contract is the whole setup. The provider list confirms it uses the platform's Google client:
GET /api/dev/v1/projects/:id/sign-in
{"providers":[{"provider":"google","label":"Google","enabled":true,"client":"platform", …},
{"provider":"github","label":"GitHub","enabled":false, …}]}
8. Apply and verify
apply_contract built the preview in seconds and listed the demo sign-ins:
Built and wired end to end:
- 4 entities (member, instructor, session, booking) with 38 seeded rows in your project database
- 8 pages of real TypeScript in src/pages/
- roles: Member, Instructor
- 4 event workflow(s) armed
Demo sign-ins:
- admin (instructor role): admin@stillwater.test / quartz-tango-slate-52
- ana (member role): ana@stillwater.test / falcon-iris-ivory-64
- ines (instructor role): ines@stillwater.test / autumn-coral-lemon-98The first apply's production check flagged that signed-out visitors could read the member table (names and emails). Claude set "access": { "member": { "read": "own", "update": "own" } } and applied again. Then it took screenshots as each role:

And ran verify:
verify →
static pass contract: 4 table(s), 2 role(s), 8 page(s), 4 workflow(s)
reachability: every page is reachable by at least one role
typecheck: tsc clean in 26s
data pass logins: Sign-in works for 3 demo logins (instructor, member)
role-matrix: Permissions match the plan for 2 roles × 4 tables
anonymous: Signed-out visitors can't read the 2 private tables (member, booking)
workflows: 4 workflows armed
backups: skipped (one is taken automatically before every publish)
browser pass walk:instructor: opened 6 page(s) with no errors
walk:member: opened 2 page(s) with no errors
nav-scope: every role is offered only its own pages
status: pass9. Deploy
deploy { "subdomain": "example-bookings", "demoReset": true } →
{
"url": "https://example-bookings.apps.appbricx.com",
"status": "live",
"durationMs": 28259,
"demoReset": { "enabled": true, "accounts": 3 }
}Deploy ran verify again, took a snapshot of production and published. demoReset puts the public demo back every night and moves the class times forward, so the schedule never goes stale.
10. Edit a row and roll back
A studio owner asks for more mats in Tuesday's Sunrise Flow. You can do it in the builder or ask Claude.
In the builder
- Open the project, then the Backend panel, and switch to Prod. The panel turns amber.
- Open Data, pick
session, changecapacityon Sunrise Flow to 20 and confirm. Production is backed up first. - To undo, open Backups, find the snapshot marked "Before a production edit" and click Restore.
With Claude
"On prod, set the capacity of Tuesday's Sunrise Flow to 20." These are the real calls and answers:
edit_rows { "table": "session", "env": "prod", "op": "update",
"id": "852eddbb-da2b-561a-9ff7-2a35523f6eb4", "patch": { "capacity": 20 } }
→ { "succeeded": 1, "failed": 0, "snapshotId": "prod-20261012T101217397Z-001-agent-editrows" }
sql { "env": "prod", "sql": "select title, capacity from session where id = '852eddbb-…'" }
→ { "rows": [{ "title": "Sunrise Flow", "capacity": 20 }], "readOnly": true }"Undo that."
snapshots { "action": "restore", "env": "prod", "id": "prod-20261012T101217397Z-001-agent-editrows" }
→ { "restored": "prod-20261012T101217397Z-001-agent-editrows",
"undoSnapshotId": "prod-20261012T101228236Z-003-prerollback" }
sql { "env": "prod", "sql": "select title, capacity from session where id = '852eddbb-…'" }
→ { "rows": [{ "title": "Sunrise Flow", "capacity": 14 }] }The restore took its own snapshot first, so even the undo can be undone. See operating your app.
11. The result


In about 30 minutes of agent time you have:
- A live app at example-bookings.apps.appbricx.com with two roles, four tables and eight pages.
- Access enforced by Postgres and proven by verify; a rule that stops overbooking for every caller.
- Custom screens with realtime updates and a chart, a server function, AI, and Google sign-in.
- A snapshot before every publish and every production edit.
Make it yours
- Apply the example to your own project: see the example README (
appbricx apply app.json). - Ask Claude for a waitlist: "when a class is full, let members join a waitlist and notify the first one when a mat frees up".
- Take payment per class with payments.
Gotchas we hit
- Rules run as the writer. A member can't read other members' bookings, so the rule checks a
bookedcount kept onsessionby a workflow. - Read the anonymous line in verify. It caught the public
membertable. - Time zones: times are stored in UTC; the components format them in
Europe/Lisbon, and the digest hastz. - Day names in seed titles age. We first seeded "Friday Slow Flow" on a Saturday. Keep titles free of dates, since demo reset moves the dates.