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).
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.
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,
calendar.freebusyscope only) to grey out mutually-busy slots. - Phase 3 —
tiny admin page (mint links, upcoming bookings)(shipped), reminders, first-party cancel/reschedule. - Phase 4 — voice agent: same
getOpenSlots()/book endpoints, third client.