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:

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

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

  1. Open /m/maple-street-auto/admin in one tab and /playground in another. Run a and watch an amber hold turn green.
  2. 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.
  3. Run f, switch to Rosie's dashboard, and press Approve. The playground continues as soon as the webhook lands.
  4. 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.
  5. 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_host webhook going out.
  6. 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.
  7. 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