#SchCP sample booking site
A sandbox for the SchCP reference implementation. It boots the simulated world from
src/sim/world.ts (four fictional businesses, a booking platform, three people whose own agents
keep their calendars on a personal-calendar service, key apps and agents, all on one machine with
a real clock) and puts one web app in front of it:
- a public website for each business, where humans book through a widget, and a profile page for each person (their calendar is private: no widget),
- a host dashboard showing holds, bookings, approvals and the live protocol log (for a person, their own calendar),
- an agent playground, where you play the consumer agent and every step is a real, signed SchCP request made through the SDK.
Humans and agents book from the same calendar, so anything one does shows up for the other. Nothing on the site moves money: SchCP v0.4 never charges anything. A stated price is shown as what the person pays the host directly; a playdate has no price at all. Cancelling is always free and records nothing; what a late cancellation or a no-show means is the host's own business, outside SchCP.
#Run it
npm install
npm run site # http://127.0.0.1:4600
PORT=5000 npm run site # another port
HOST=0.0.0.0 npm run site # listen on all interfaces
Node 22.18 or later runs the TypeScript directly. There is no build step and no extra
dependency: plain node:http, static HTML and CSS, and vanilla JS. State lives in memory and
resets when you restart. On boot the site seeds a few web and phone bookings, a lunch break and
some walk-ins in Rosie's calendar, and two weeks of the people's own commitments (school pickup,
swim lessons, football practice) in Noor's and Ellis's calendars.
#Pages
| Route | What it is |
|---|---|
/ |
Overview: what SchCP is, the four businesses, the three people, and what to try |
/m/{id} |
A business's public site: services, stated prices (paid to the business directly), hours, policies (cancelling is always free through SchCP; the waitlist, booking for someone else, and reconfirmation when the host has them), the discovery document, and a human booking widget (engine.webBook). For a person: what you can ask them for, the rules their agent keeps (contacts only, no price, open-ended playdates), and no widget |
/m/{id}/discovery |
The host's /.well-known/scheduling.json, pretty-printed (/m/{id}/scheduling.json serves the raw JSON) |
/m/{id}/admin |
Host dashboard (?date=YYYY-MM-DD picks the day) |
/playground |
Agent playground (?scenario=…&host=…&run=1 starts a run straight away) |
Host ids: maple-street-auto, fades-on-queen, harbourview-dental, rosies-nails, and the
people noor-haddad and ellis-moreau (on daybook.example, contacts only; Ellis approves each
request, Noor accepts requests from her contacts that fit).
Add ?theme=dark or ?theme=light to any page to override the system colour scheme.
#Host dashboard
- Day timeline. One row per resource, one column per hour. Shading marks hours when that resource isn't working. Colours: hold amber, with a live countdown to expiry; booking green; tentative green with a dashed border; needs approval orange; proposed move purple; calendar event grey. A red line marks the current time.
- Needs attention. Active holds, bookings waiting for approval, and open proposals, from any day.
- Needs attention also lists tentative bookings waiting for a participant to confirm (and bookings waiting for the booker to reconfirm), with the deadline counting down, and open change offers with who asked for them. A hold the host placed for a waitlist entry is labelled waitlist offer.
- Bookings for the day. Each shows its channel (◆ agent, ◎ web, ☎ phone), its stated all-in
total (or "no price"), and the host's actions: approve or decline a pending booking (decline asks for a registered
reason:
fully_booked,service_not_offered,needs_consultationorother, plus a message), cancel with a reason (the agent receives a signedbooking.cancelled_by_hostwebhook), propose a new time, and, once the appointment has started, mark it completed or a no-show (a booking status: a no-show bumps the sequence and sends the signedbooking.no_showwebhook, and nothing more). Where the host keeps a waitlist, decline and cancel offer Release the time to the waitlist; leave it unticked when the time itself is the problem. Under each booking: its price lines when there are several, where it happens (a place the guest named), "open-ended" for a booking with no agreed end, participants and their status (pending with its deadline, accepted, declined, named only), the refusal on a declined, cancelled or expired booking, and the open change offer (who asked, the new time, its all-in total, and theprevhash it chains from). + Phone booking adds a booking the way a front desk would (businesses only). - Waitlist for the day: each entry's status (waiting, offered, booked, left, lapsed, expired), its place in line, and the hold offered to it with a countdown.
- Protocol event log. Fed by Server-Sent Events from
engine.on('event'), plus the webhooks delivered to the playground agent. - Rosie's calendar (Rosie's Nails only). Lists the events in the shared calendar, marking which ones Rosie typed and which ones the adapter wrote. Sync calendar runs the calendar adapter. Add walk-in writes an event into the calendar the way Rosie would. Agents can't see a walk-in until you sync, which is the reason calendar hosts approve every booking.
#Agent playground scenarios
| Scenario | What happens | |
|---|---|---|
| a | Book my oil change Thursday ~1 PM | bookAppointment: discovery, a mandate scoped to Thursday 11 AM–3 PM under $100, availability, hold, the human's OK, confirm, then a verified receipt and a management mandate. Uses the next Thursday, and moves to the Thursday after if that day is full. Works with every host. |
| b | Two agents race for the same barber slot | Alice's agent and Bob's agent (different keys, different key apps) send POST /holds for the same slot with Marcus at the same moment. One gets 201. The other gets 409 SLOT_UNAVAILABLE with alternatives, takes the nearest one, and both confirm. |
| c | Hold and walk away | A hold with a 45 s TTL that the agent never confirms. Watch it count down on the dashboard and expire. A signed hold.expired webhook arrives, a late confirm gets 409 HOLD_EXPIRED, and the slot is free again. |
| d | Price above the mandate's limit | The mandate's max_price is 75 % of the list price, so the hold is refused with 403 MANDATE_LIMIT and nothing is allocated. |
| e | Cancel: always free (late-cancel) |
Sam's agent books the first open slot in the coming week, then cancels it under the booking's management mandate. Cancelling through SchCP is always free, at any notice, and records nothing; any policy of the host's own is between the guest and the host. The host returns a new signed receipt (cancelled, sequence + 1), and the time is freed: to the first waitlist entry it suits, where the host keeps a waitlist, and otherwise straight back on sale. |
| f | Calendar host needs approval | Rosie's returns 202 pending_host and writes a TENTATIVE event into her calendar. Approve or decline it on Rosie's dashboard. The playground waits up to 15 minutes for the signed webhook. |
| g | Spoofed endpoint | Two lookalike discovery documents: one points its endpoint at collector.evil.example, the other claims an operator that doesn't list it. The agent refuses both before sending a signed request, and the collector's hit counter stays at zero. |
| h | Dentist: the patient signs the exact offer | Harbourview publishes closed_mandate: "always": the patient signs every offer itself. Confirming with the open mandate gets 403 OFFER_APPROVAL_REQUIRED. An agent that rewrites the offer to show half the price and has Sam sign that gets 403 MANDATE_LIMIT, because the hash doesn't match. The honest flow, where the key app renders the offer's own terms, books, and the receipt carries offer_sha256. |
| i | A script floods the barbershop with new identities | Eight throwaway principal keys try to hold Marcus's afternoon. Six get holds (max_active_unproven) and the rest get 429 HOLD_LIMIT. A newcomer whose key app carries a PhoneCheck attestation draws on PhoneCheck's separate pool and still gets a hold. |
| j | Waitlist: a cancellation goes to the first person waiting | At Fades, Alice's agent books a time with Marcus and the front desk puts a walk-in with Priya at the same time, so availability is empty. Sam's agent joins the waitlist for that time (POST /waitlist). Alice cancels; in the same transaction the host places a hold for Sam and sends a signed waitlist.offered webhook. Public availability for that time stays empty throughout. Sam's mandate covers the offer, so his agent confirms it without asking him again. |
| k | Book for two: a second person confirms from their own key app | At Fades, Sam books a haircut for his brother Alex and names him as a required participant. The confirm returns 202 tentative with Alex's confirm_by deadline. Alex's agent reads the booking under Alex's own mandate, verifies the signed contract and its hash, and you decide whether Alex accepts or declines (POST /bookings/{id}/respond). Accepting confirms the booking (sequence + 1); declining releases it with refusal reason participant_declined. Sam's agent hears booking.response and the status change. |
| l | Playdate: two parents' agents find a time | Noor's agent uses arrangeMeeting: one mandate from Noor's key app names both calendars (aud is a list) and a weekend. It keeps the starts both calendars offer, holds the time on both at the park, and writes one meeting contract naming both people and each calendar's signed offer. Noor approves its text once; her signature is her yes, so her calendar holds it tentative. The playdate is open-ended (each calendar sets three hours aside) and has no price. Ellis has signed no rules, so Ellis's calendar goes pending_host, and you answer for Ellis: yes signs an acceptance of the contract's hash and Noor's agent commits it on both calendars; no declines it, and Noor's agent cancels her side, which never became final. |
| l3 | Playdate for three families: one contract, one hash (playdate-three) |
The same with Priya too. Priya's agent says yes by a rule Priya signed ("Weekend playdates with my contacts"), and the booking says so. You answer for Ellis: yes, no, or suggest Christie Pits instead. A suggestion is a counter: Noor approves the revised text, Priya's rule answers the new hash by itself, and Ellis is asked again. Nothing is confirmed on any calendar until everyone has said yes to the same contract hash. |
| m | A stranger asks for a person's time | Pat, who is not on the list, asks for coffee with Noor (or Ellis, or Priya). Discovery is public, but availability (anonymous or with Pat's mandate) and a guessed hold all get 403 NOT_PERMITTED with reason not_a_contact; the list itself is never published. The same question from the other parent, who is a contact, returns the free times. |
Each trace step shows the method, the public URL, the status, the time taken and the key fields. Expand a step to see the request and response. Mandates appear when the agent first uses them, along with their constraints and the human's consent statement.
#Things to try
- Open
/m/maple-street-auto/adminin one tab and/playgroundin another. Run a and watch an amber hold turn green. - Book a slot on
/m/maple-street-auto, then check availability in the playground (or run a again). The slot you booked is no longer offered. - Run f, switch to Rosie's dashboard, and press Approve. The playground continues as soon as the webhook lands.
- On Rosie's dashboard, add a walk-in at a time an agent can book. The public widget still offers that time until you press Sync calendar.
- Cancel an agent booking from a dashboard. Bookings made in the playground carry the
playground's callback URL, so the event log shows the
booking.cancelled_by_hostwebhook going out. - Open Ellis's calendar (
/m/ellis-moreau/admin) and run l. The playdate appears on both calendars; Ellis's stays orange until you answer for Ellis. - Run j with Fades' dashboard open on the day it picks: the waitlist panel shows the entry go from waiting to offered (with its countdown) to booked. Run k and leave Alex's question unanswered for a moment: the booking sits in Needs attention as tentative until you answer.
#Reaching the SchCP hosts from outside
Port 4600 is the site, not SchCP. Each simulated domain (maplestreetauto.example,
chairly.example, rosiesnails.example, …) is its own HTTP server on a local port the
operating system picks at start-up. npm run site prints the map, and
GET /api/dns returns it as JSON ({"maplestreetauto.example": "http://127.0.0.1:41234", …}).
A client written elsewhere (another language, another SDK) uses it as its resolver: it
addresses https://maplestreetauto.example/schcp/v0/…, signs that public URL as @target-uri
(§3.2), and sends the request to the local origin the map gives. The hosts reconstruct the
public URL themselves, so the signature verifies. implementations/python does this with
--resolve maplestreetauto.example=http://127.0.0.1:PORT.
#API used by the pages
These are site endpoints, not SchCP. The SchCP endpoints live on the simulated host hosts.
GET /api/dns public hostname → local origin of every simulated domain
GET /api/m/{id}/slots?service=&date=[&resource=] open slots for one local day (for a person: their own view)
POST /api/m/{id}/book {service_id,start,name,phone?,resource?,channel?}
GET /api/m/{id}/dashboard?date=
GET /api/m/{id}/events Server-Sent Events (history, protocol, webhook, site)
POST /api/m/{id}/admin/{approve|decline|cancel|propose|no-show|completed}
{booking_id, reason?, message?, release_to_waitlist?, start?}
decline: reason ∈ fully_booked|service_not_offered|needs_consultation|other (default other)
cancel: reason is free text, sent to the guest
POST /api/m/rosies-nails/calendar/sync
POST /api/m/rosies-nails/calendar/walkin {date,start,end,name}
POST /api/playground/run {scenario,host?,auto?} → NDJSON trace stream
POST /api/playground/answer {run,id,value}
#Checking the scenarios
npm run site:check # every scenario against every host it supports
npm run site:check -- --only=waitlist,participants --verbose
npm run site:check -- --only=participants --auto=decline
It boots the same world as npm run site (no HTTP front door), answers questions with approve
(or --auto=…), prints ok or BAD per run with its result, and exits non-zero on any BAD.
It skips calendar-approval, which waits for a person on Rosie's dashboard, and walk-away,
which waits out a 45 s hold.
auto answers human-in-the-loop questions automatically, for example
{"scenario":"participants","auto":"approve"}, which is handy with curl -N.
#Files
main.tssets up the HTTP front door, routing, and the JSON, SSE and NDJSON endpoints.site.tsboots the world and holds the host registry (businesses and people), the SSE bus, the lookalike hosts, the seed data and the dashboard view model.snapshot-extras.tsholds read-only queries the dashboard needs beyondEngine.snapshot(): waitlist entries, bookings waiting on participants, and decoded change offers. It reads the host's store directly and never writes.check-scenarios.tsruns every playground scenario headlessly (npm run site:check).scenarios.tscontains the playground scenarios and run plumbing (questions, webhook routing).trace.tswrapsfetchso that every SDK call becomes a trace step.pages.tsrenders the server-side page shells.static/holds the stylesheet (design tokens as CSS variables,light-dark()for both themes) and one script per page.screenshots/holds captures for the whitepaper, taken by hand from the v0.3 site; they predate the people and the removal of money.