Appbricx
Docs
FeaturesPricingStart free

Get started

OverviewWhat Appbricx isQuickstart with a coding agentQuickstart with the builderCore concepts

Guides

Data and tablesAccess and rolesBusiness rulesSign-inCustom screensRealtime and topicsWorkflows, schedules and webhooksServer functionsAI in your appPaymentsFilesMessagesIntegrationsVerifyPublishing, domains and rollbackOperating your appExport and self-hostSelf-host install

Tutorials

Build a booking app with Claude CodeMulti-user todo listClient portalSlack on new sign-upTemplates

Reference

Reference homeFrontend SDKHTTP APIMCP toolsCLIIntegrations catalogContract formatAI key resolutionRun Appbricx locally

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.

Stillwater's member schedule: an AI suggestion for Desk Reset Pilates, day chips, and a Sunrise Flow class with a forecast of some cloud and 17 degrees
What you will build: the member schedule, with the AI class finder and the forecast server function.

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/mcp

Or 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).
  • access on member and booking: members read and change only their own rows.
  • actor_ref on booking: the server fills member_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 the class-forecast server 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>
  );
}
Your classes: three upcoming classes, each with a Cancel button
My classes, signed in as Ana (from the screenshot tool, which frames the preview with both navigation rows).

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>
  );
}
Studio desk: a bar chart of mats booked for the next seven days and two class rosters with Here and No-show buttons
The instructor desk on the live app, signed in as Inês.

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, …}]}
Stillwater sign-in screen with Continue with Google, email and password fields, and a Sign in button in the brand green
The sign-in screen in the brand colour, with Continue with Google.

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-98

The 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:

Phone-size screenshot of the member schedule from the screenshot tool
screenshot { as: "member", route: "/schedule" }: the phone-size view Claude checked.

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: pass

9. 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

  1. Open the project, then the Backend panel, and switch to Prod. The panel turns amber.
  2. Open Data, pick session, change capacity on Sunrise Flow to 20 and confirm. Production is backed up first.
  3. 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

The member schedule on a phone: AI suggestion, day chips, and two classes
The member schedule on a phone, on the live app.
The instructor's Classes page: a table of the week's classes with instructor, level, room, booked and capacity
Instructors manage the timetable from a generated list and form, no component needed.

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 booked count kept on session by a workflow.
  • Read the anonymous line in verify. It caught the public member table.
  • Time zones: times are stored in UTC; the components format them in Europe/Lisbon, and the digest has tz.
  • 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.

Related

  • The example on GitHub
  • Templates
  • Custom screens
  • Operating your app
NextMulti-user todo list
Appbricx

The backend, runtime and hosting for apps your coding agent builds. On our cloud, in yours, or on your own servers.

Features

Coding agents and MCPVerify testingBuilder consolePostgres databaseData APIRealtimeRow-level securitySign-in and authPaymentsWorkflows and jobsAI in your appIntegrationsBackups and rollbackHosting and domainsSelf-host and exportAll features

Product

ShowcasePricingFor coding agentsTemplatesPrivate cloudBring your own AI keyFAQ

Compare

Appbricx vs SupabaseLovable alternativeBolt alternativeFor freelancersFor agencies

Resources

DocumentationReferenceBlogSupportContact

© 2026 Appbricx. All rights reserved.

TermsPrivacyCookiesAcceptable Use