---
name: calendar-site
description: Stand up a public "book time with me" page on a custom domain, backed by the account calendar. This is the skill behind "give me a booking link", "put up a Calendly-style page", "let people book a call with me", "deploy the booking site", and "set my availability for bookings". It writes the account availability window (one duration, one weekly window, one buffer), assembles the booking page plus its D1 capture into the canonical Pages tree, provisions the D1 bookings table, deploys to Cloudflare Pages via the site-deploy skill on the booking domain, and gates done on a live booking page whose open slots load from the brand tunnel and whose test submission lands in D1. The admin calendar that shows the resulting meetings is served separately at /calendar on the admin host (resolve it with the admin-hostname tool, never the public host, which 404s /calendar) and needs no deploy. Admin-run; holds Bash and the account secrets surface.
---

# Stand up a calendar booking site

Run by the admin/main session, the surface that holds `Bash` and reads the account secrets file. This skill composes `cloudflare:site-deploy` for the Pages deploy and adds the two things a booking site needs that a plain landing page does not: the account availability config the platform reads to compute open slots, and a `bookings` D1 table written by the booking page rather than a contact form.

This is an outcome contract, not a copy of the Pages mechanics. The deploy, custom-domain attach, token discipline, and live done-gate are owned by `site-deploy`; read it with `plugin-read` before deploying. The D1 mechanics live in `references/d1-data-capture.md` and the token discipline in `references/api.md`; read those before issuing their commands.

## What this skill produces, and what it does not

- It produces the **public booking page** on a custom domain (the booking domain), backed by a D1 `bookings` table.
- It writes the **availability config** the platform free/busy endpoint reads.
- It does **not** deploy the admin calendar. The four-view admin calendar (Day, Week, Month, Year) is served by the install itself at `https://<admin-host>/calendar`, behind the existing admin sign-in. The `<admin-host>` is **not** the public host; `/calendar` 404s on the public host. Resolve the admin host with the `admin-hostname` tool (the admin-host twin of `public-hostname`; it reads the cloudflared ingress and returns the host that is neither public nor operator), then point the operator at `https://<admin-host>/calendar` once the booking site is live. There is nothing to deploy for it.

## The two routes and why only one is deployed

A booking visitor and the admin look at the booking from opposite sides — the visitor sees only free/busy, the admin sees the booked meeting in full alongside the rest of their schedule:

| Surface | Where it lives | Gate | Reads |
|---|---|---|---|
| Admin calendar (4 views) | the install, at `/calendar` on the admin host (resolve via `admin-hostname`) | admin sign-in | every time-bound item for the account — booked and imported meetings, agent-scheduled appointments, and tasks with a due date |
| Public booking page | Cloudflare Pages, on the booking domain | open | free/busy only, never any meeting detail |

Only the public page is deployed here, because it must stay reachable and keep capturing bookings even when the install device is offline. The admin calendar is dynamic, account-scoped, and authenticated, so it is served by the install over the tunnel and is same-origin with its data. Do not try to put the admin calendar on Pages.

## Availability survives the device going offline

The booking page does not fetch open times from the device. It reads them same-origin from its own edge Function, `functions/api/calendar/free-busy.ts`, which serves a snapshot stored in the D1 `availability_snapshot` table minus any slot already booked (so a taken slot disappears at once, and two visitors never see the same slot) and with past slots filtered out (so a stale snapshot never offers an expired time). The snapshot is kept fresh by a publish job the platform server arms on a short timer: while the device is online it pushes the account's computed free/busy into `availability_snapshot`; while the device is off it simply does not run and the edge keeps serving the last snapshot until the device returns. There is no per-account cron or setup; the server arms it for the account it resolves, which is the one account whose booking site this install serves.

## Set the availability window first

The public page shows open slots computed as the weekly window minus busy `:Meeting` intervals minus a buffer. Those inputs live in one file the platform reads:

```
<accountDir>/calendar-availability.json
```

Collect from the operator, then write the file: the account `timezone` (IANA), the meeting `durationMins`, the `bufferMins` between meetings, and the `weekly` window as `{ mon: [["09:00","17:00"]], ... }` with an empty array for days that are closed. Also collect `daysAhead` (the booking horizon — a positive integer count of days the page lets visitors book into, e.g. `14`) and `allowMultiSlot` (a boolean: may a visitor combine consecutive open slots into one longer back-to-back booking? default `false`). Add the `bookingDomain` (the custom domain the page will answer on) and `bookingDbName` (the D1 database name chosen below). Re-running this skill with new numbers is how availability — including the horizon and the multi-slot choice — is changed later; there is no separate editor. The device-side job that keeps the edge snapshot fresh is armed by the always-running platform server, so there is nothing per-account to schedule.

## Assemble the canonical tree

Copy the skill template into the one canonical Pages source path `site-deploy` expects:

```
<accountDir>/pages/<project>/
```

The template ships `public/index.html`, `public/booking.js`, `public/booking.css`, `functions/api/book.ts`, `functions/api/calendar/free-busy.ts`, `wrangler.toml`, and `schema.sql`. The substitutions that make it concrete are: in `public/index.html`, set `<meta name="days-ahead">` content to `daysAhead` and set `<meta name="allow-multi-slot">` content to `allowMultiSlot` as the literal `true` or `false`; and fill `wrangler.toml`'s `__PROJECT_NAME__`, `__D1_DATABASE_NAME__`, and `__D1_DATABASE_ID__` from the project name and the database created below. Leave `<meta name="api-base">` empty as it ships; the page reads availability same-origin from the site's own `free-busy` edge Function (see the snapshot section below), never the device tunnel. Leave no `__...__` placeholder in the assembled tree.

## Provision the D1 bookings table

The booking-specific schema ships with the template: `schema.sql` creates both the `bookings` table (written by the booking page) and the `availability_snapshot` table (the single-row free/busy cache the publish job upserts and the `free-busy` edge Function reads). Applying `schema.sql` provisions both. Load the stable `<brand>-pages-d1` token from the account secrets file, minting it once only if absent and persisting it, exactly as `references/api.md` and `references/d1-data-capture.md` prescribe; the token needs both Pages Edit and D1 Edit. Create the database, record its id into `wrangler.toml`, and apply `schema.sql` to the remote database. The token is never written into the tree, never committed, never echoed.

## Deploy through site-deploy

Hand the assembled tree to `cloudflare:site-deploy`: it builds nothing for a static tree, runs `wrangler pages deploy public --project-name <project> --branch=main`, attaches the booking domain (creating the DNS record with the `<brand>-dns` token when the attach leaves validation pending), and reuses the same per-scope token. Everything in `site-deploy`'s auth, custom-domain, and DNS guidance applies unchanged — including its multi-tenant branch: "admin-run" means a `role:"admin"` session, which on a multi-tenant install is **not** the same thing as the house. A client account's admin session is denied the `pages` scope (`reason=not-house`) and publishes with `storage-pages-deploy` instead. For a booking page on a brand-new project, that client session first creates and claims the project with `storage-pages-create` and then publishes with `storage-pages-deploy`; the create is what gives the new project an owner, without which the first deploy fails at `reason=unregistered`.

## Done-gate

Done is a live behavioural signal on the booking domain, surfaced verbatim:

- The booking domain returns `HTTP/2 200` on a cache-busted request.
- The page's free/busy call succeeds same-origin: `curl` the booking domain's own `free-busy` edge Function and confirm it returns a `slots` array (with `updatedAt`) and carries no meeting title, description, or location field. Immediately after deploy it may return `{"slots":[],"updatedAt":null,"stale":true}` until the platform server's publish job runs once (within ~5 minutes); re-check until `updatedAt` is fresh and `slots` is populated. A response missing the `slots` field means the edge Function did not deploy; empty-with-`stale` after several minutes means the publish job is not reaching D1 (check the brand journal for `[calendar-availability]`).

```bash
curl -s "https://<booking-domain>/api/calendar/free-busy?from=$(date -u +%Y-%m-%dT00:00:00Z)&to=$(date -u -v+14d +%Y-%m-%dT00:00:00Z)" | head -c 400
```

The device's own free/busy (the publish job's source) should also return a `slots` array; a `404` there means the availability window was not written:

```bash
curl -s "https://<brand-tunnel-domain>/api/calendar/free-busy?from=$(date -u +%Y-%m-%dT00:00:00Z)&to=$(date -u -v+14d +%Y-%m-%dT00:00:00Z)" | head -c 400
```

- A test submission lands in D1 unswept, reusing the same token and database name already loaded:

```bash
curl -sS -X POST -H 'content-type: application/json' \
  -d '{"slotStart":"2026-01-01T09:00:00Z","slotEnd":"2026-01-01T09:30:00Z","name":"verify","email":"verify@example.com"}' \
  "https://<booking-domain>/api/book" -i | head -1
CLOUDFLARE_API_TOKEN="${PAGES_D1}" wrangler d1 execute <db-name> --remote --command \
  "SELECT bookingId FROM bookings WHERE swept = 0;"
```

The install reconciles accepted rows into `:Meeting` nodes on its own interval, so the test submission will appear in the admin calendar at `/calendar` within a couple of minutes and can be removed there or left as proof. Tell the operator the booking link (the booking domain) and the admin calendar link (`https://<admin-host>/calendar`, with `<admin-host>` resolved via the `admin-hostname` tool, not the public host) in the result.

## Tool discipline

The permitted surface is `wrangler`, `curl`, and the Cloudflare API via Bash following the cloudflare references, using the reused per-scope token. Do not mint a fresh token per deploy, do not drive the dashboard with a browser, do not synthesise `wrangler` flags from web search, and never write or echo a token. When a step fails, report the exact output with secrets redacted, name the failing URL or command, and stop.
