Self-hosted scheduling — a simpler, rule-driven, open-source Calendly
replacement. One page, three password tiers, Google Calendar invites.
Zero npm dependencies (Node ≥ 22.5: node:http, node:sqlite, fetch).
Guests follow a link (or type a password), see open slots in their own timezone, pick one, enter name + email — the event lands on the owner's Google Calendar with the guest as attendee, and Google sends the invite email (with Meet link, accept/decline, the works). No SMTP.
All personalization lives in one gitignored env file. To run your own:
git clone <your-fork> && cd clawd-calendar
cp .env.example .clawd-calendar.env # edit: your name, timezone, hours, rules
node scripts/auth.js # one-time Google OAuth (fills in the secrets)
node scripts/mint.js "friends" --token <pick-a-password>
node server.jsNothing in the repo is personal — no code edits needed. Every knob
(CAL_OWNER_NAME, CAL_OWNER_TZ, availability windows, slot shape,
buffers, daily cap, event title…) is documented in
.env.example; real env vars override the file.
Personal images live in a gitignored assets/ folder served on top of
public/ — drop in avatar.jpg (set CAL_AVATAR_URL=/avatar.jpg),
favicon-32.png, apple-touch-icon.png, and an og.png (1200×630 unfurl
card) and they're picked up at those URLs without being committed.
Slots are generated by one pure function (lib/slots.js), governed by
env config:
- Window — e.g. weekdays 8:00–17:00 in
America/Denver(DST-safe). - Busy subtraction — owner's Google free/busy removes conflicts.
- Prep buffer — e.g. 15 min needed before each slot (checked against busy events, not the window edge — 8:00 stays bookable even though prep would be 7:45).
- Slot shape — e.g. 60-min calls on 30-min start increments.
- Daily cap — one call per day; a booked day disappears. The calendar
is the source of truth: delete a booked event from Google Calendar and
its day reopens (the local booking is auto-cancelled), and for types
with a fixed event title (e.g.
SLOP.COMPUTER) any calendar event with that name in its title or description — even one you added by hand — spends the day (Prepare: …companion blocks don't count). - Min notice / max days out.
- Book-time re-check — rules re-run for the exact slot before the event is created; a race loses gracefully with "slot just taken".
The password is the token (/a/<password>, or typed on the landing page):
| tier | window | busy | daily cap |
|---|---|---|---|
standard |
config window | subtracted | enforced |
vip |
any day, wide hours | subtracted | bypassed |
override |
any day, wide hours | ignored — owner moves things | bypassed |
Mint them from the CLI (--token sets a memorable password, otherwise random):
node scripts/mint.js "friends" --token <password>
node scripts/mint.js "Vitalik" --tier vip
node scripts/mint.js "surprise guest" --tier override --max-uses 1
node scripts/mint.js "podcast" --duration 90 --days sat,sun --start 09:00 --end 12:00
node scripts/mint.js --listPer-token overrides (window/duration/cap) work on any tier.
No-password ("public") mode — mint a link with --public and its route
works bare: node scripts/mint.js "walk-ins" --public --duration 30 opens
/ itself for booking; add --type slop and /slop opens instead. The
public token is still a normal link (tier, overrides, use counts) — if it
gets abused, disable it (CLI or admin page) and the password gate is back.
A wrong password never falls back to the public token.
Beyond the default call, each event type is its own route
(/<key>/<password>) with its own landing style and rules — duration,
weekly window, per-type daily cap (office hours filling up doesn't consume
the one-call-per-day budget), min notice, calendar event title, page
title/description, and an accent color that re-themes the whole page.
A type can also customize the calendar invite itself: custom
description text (--event-desc), a location (--location), Meet link
on/off (--meet/--no-meet), and a prep block (--prep 15 — an
owner-only "Prepare: …" event created on the calendar right before each
booking; the slot search keeps that gap clear of busy events) and a
wrap-up block (--wrap 15 — the same thing after: an owner-only
"Wrap up: …" event, and no slot may end within that gap of a busy event).
For a fully custom look a type can carry a per-type avatar (--avatar),
an ASCII banner rendered above the picker (--ascii-file), and a named
skin (--skin) — a CSS theme in index.html (e.g. slop).
Anything unset inherits the env config. Resolution chain:
env config → event type → token (tiers still work inside any type).
node scripts/types.js "Office Hours" --key office --duration 30 \
--days fri --start 14:00 --end 16:00 --daily-cap 4 \
--accent "#f5a623" --desc "Drop-in office hours — 30 min, Fridays." \
--event-title "Office hours: {name}"
node scripts/mint.js "students" --type office --token <password> # → /office/<password>
node scripts/types.js --list
node scripts/types.js --disable office # kills all its links (--enable undoes)A password typed on the landing page finds its own event type — the page redirects itself to the right route.
A "Connect your Google Calendar" hero tops the picker (needs a separate
"Web application" OAuth client — GCAL_WEB_CLIENT_ID/_SECRET; unset =
feature off). The guest consents with the free/busy scope only; their busy
blocks land in their browser's sessionStorage (green = works for both,
red = they're busy, plus a whole-day timeline view), the booking form
prefills their name + email from the Google account (profile scope,
non-sensitive) so they just hit go, and the page re-polls their free/busy
straight from Google every 30s (and instantly on tab return) using the
short-lived token — the server never stores it. A footer at the bottom
shows "You are connected as Name (email)" with a Disconnect button.
One calendar does everything. Every booking mints an unguessable manage
key; when the calendar knows the guest's booking (their browser's
localStorage remembers it, or they arrive via the /r/<key> link in
their invite — which seeds localStorage and rewrites the URL back to the
plain route), the page flips into manage mode: it opens on their booked
month/day with their slot marked blue, the banner reads "you have an
appointment — select a different time to reschedule, or cancel", and
picking any slot moves the Google Calendar event in place (guests get the
update email; the owner's prep + wrap blocks move too). Cancel deletes the
event + prep/wrap blocks, emails the cancellation, and frees the day for the cap.
Their own event never blocks a new pick: its busy time and its daily-cap
day are excluded while they choose. A "booking a separate call" escape
hatch covers the second-meeting case. The invite-description link and the
admin page's copy-reschedule-link button are how guests on a fresh browser
get back in.
Bookings aren't solo-only. The booking form has an "+ Add guests" field
(comma-separated emails, up to 5) — everyone lands on the Google Calendar
event as an attendee and gets the real invite, Meet link and all. Already
booked? The manage view shows a "👥 Also invited" panel: invite one more
by email or ✕-remove one, and the server PATCHes the event's attendee list
(sendUpdates=all, so Google emails the newcomer the invite and the
removed guest the cancellation). No SMTP, same as everything else.
Typing addresses is the default — but a "📇 Search my contacts" button next to the guest inputs offers Gmail-style autocomplete as a deep opt-in: nothing happens at connect time or automatically; only clicking the button opens a second, separate Google grant (contacts + other-contacts read scopes, in a popup so the form keeps its state). After that, typing a name suggests people they know. The short-lived token goes straight to the guest's browser, which queries the People API directly — the server never sees a contact and nothing is stored. Needs the People API enabled on the same Google Cloud project as the web OAuth client; until it is, the button still works but returns no suggestions.
Set CAL_ADMIN_PASSWORD and /admin/<password> becomes a one-page admin:
upcoming + past bookings, every link (copy/disable/enable, use counts), and
event types (create, disable, mint a link straight into one). It does the
same things scripts/types.js / scripts/mint.js do over ssh — use
whichever is closer. Unset (the default), the route doesn't exist.
node scripts/auth.js walks you through it (full steps in the script
header): create a Google Cloud project → enable the Calendar API → OAuth
consent screen (External, then Publish so the refresh token never
expires) → create a "Desktop app" OAuth client → paste the id + secret.
Secrets land in .clawd-calendar.env; the server also falls back to
~/.config/gcal-skill/credentials.json. SQLite db lives in data/
(override with CAL_DB).
npm run dev # GCAL_FAKE=1 — canned busy blocks, fake event creation
npm test # rules-engine test suite (node --test)Events created by the tool are tagged
extendedProperties.private.scheduler=true on Google Calendar; the local
bookings table is the daily-cap fast path and audit log. Google Calendar
stays the source of truth for busy time.
- Phase 2 —
optional guest calendar overlay (Google OAuth,(shipped — with live 30s refresh and name/email prefill).calendar.freebusyscope only) to grey out mutually-busy slots - Phase 3 —
tiny admin page (mint links, upcoming bookings)(shipped),first-party reschedule + cancel(shipped), reminders. - Phase 4 — voice agent: same
getOpenSlots()/book endpoints, third client.