A weekly, headless Claude Code job that discovers
events across the Raleigh / Triangle metro, deduplicates and enriches them,
publishes an interactive site to Cloudflare Pages, and emits a subscribable
events.ics calendar.
Spec:
CLAUDE.mdis the authoritative design (architecture, coverage, event schema, verification, dedup). This README is setup + operations.
.
├── CLAUDE.md # authoritative spec (incl. the Dedup design)
├── run.sh # cron entrypoint: claude -p → validate → build → push
├── crontab.txt # cron snippet (not auto-installed)
├── prompts/weekly.md # the headless task prompt (mirrors CLAUDE.md sections)
├── data/
│ ├── events.json # canonical store (envelope + events[]) — starts empty
│ ├── itineraries.json # curated outings — starts empty
│ ├── sources.json # seed discovery registry (the Phase A floor)
│ ├── source_coverage.json # per-run discovery telemetry (written each run)
│ └── archive/ # weekly <ISO-week>.json snapshots
├── scripts/
│ ├── build_ics.ts # data/events.json → public/events.ics (RFC 5545)
│ ├── validate.ts # events + registry + coverage checks (+ --check-links)
│ ├── publish_data.ts # copy data/*.json → public/ for the site to fetch
│ ├── lib/{ics,dedup,types}.ts
│ └── *.test.ts # node --test unit tests
├── site/ # Vite + React + TS app (built into ../public)
└── public/ # Pages build output (gitignored; regenerated each run)
Note on
data/itineraries.json: the original target layout only namedevents.json. Output #2 (curated itineraries) needs its own store, so this repo addsdata/itineraries.jsonwith the same envelope convention.
Note on the discovery files:
data/sources.jsonis the seed registry each run sweeps first — a floor for discovery, not its search space — anddata/source_coverage.jsonis the telemetry that run writes back (per-seed hits, zero-hit seeds, off-registry share). The coverage file isn't in the repo until a run produces one;validateskips it when absent and checks it when present. The two-phase rules live inCLAUDE.md§ Weekly run flow andprompts/weekly.mdstep 3 — read those rather than a copy here.
- Node ≥ 20 (developed on Node 22).
node --version. - Claude Code CLI authenticated for headless runs (see below).
- A Cloudflare account (for Pages) and a git remote (Pages deploys on push).
Two npm projects: the root (pipeline scripts) and site/ (the React app).
# from the repo root
npm install # script deps: tsx, typescript
npm test # 65 unit tests (ics + validate + dedup)
npm run validate # checks data/events.json + data/sources.json (empty store → 0 errors)
# the site
npm --prefix site install
npm --prefix site run dev # http://localhost:5173 (renders SAMPLE data)With no real data yet, the site falls back to bundled sample events/itineraries
(site/src/data/sample*.ts) so you can see every view, filter, and the itinerary
tab render. A banner notes "Showing sample data".
npm run build
# = validate → vite build (→ ../public) → copy events.json/itineraries.json
# → build_ics (→ public/events.ics)Open public/index.html via a static server (don't file:// it — the app fetches
/events.json):
npx serve public # or: python3 -m http.server -d public 8080run.sh calls claude -p non-interactively. Authenticate once on the machine
that will run cron:
claude # sign in interactively once (OAuth or API key), then quit
claude -p "say hi" --dangerously-skip-permissions # smoke-test headless mode- For an unattended runner, set
ANTHROPIC_API_KEYin the environment instead of interactive login, or ensure the stored OAuth token is present for the cron user. --dangerously-skip-permissionsis only used insiderun.sh(a sandboxed, non-interactive context). Never use it in an interactive session.- Keep keys out of git.
.env,.env.*, and.dev.varsare gitignored.
⚠️ This is an account action. Do it yourself — the scaffolding step does not touch your Cloudflare account.
Option A — connect to git (recommended; auto-deploys on push):
- Push this repo to GitHub/GitLab.
- Cloudflare dashboard → Workers & Pages → Create → Pages → Connect to Git.
- Pick the repo, then set:
- Framework preset: None / Vite
- Build command:
npm install && npm --prefix site install && npm run build - Build output directory:
public - Root directory: repo root (leave default)
- Node version: set env var
NODE_VERSION=20(or newer)
- Deploy. Every push to the production branch redeploys.
Option B — direct upload from the runner (no git build on Cloudflare):
npm run build
npx wrangler pages deploy public --project-name=triangle-weekendThe spec serves the site "at a path under cortech.online". Two ways:
- Subdomain (simplest): add a custom domain like
triangle.cortech.onlineto the Pages project (Pages → your project → Custom domains). Keepsite/vite.config.tsbase: "/". The calendar lives athttps://triangle.cortech.online/events.ics. - Sub-path (
cortech.online/triangle/): setbase: "/triangle/"insite/vite.config.ts, rebuild, and route that path to this Pages project (e.g. via a Cloudflare Worker / Pages path on the apex). Subdomain is less fiddly; prefer it unless you specifically need the apex path.
Update the subscribe URL wherever you share it to match the final domain.
Once Claude Code is authed and (optionally) the Pages project exists:
# Dry, deterministic pieces first (no model, no network writes):
npm run validate && npm run build
# Full weekly run (two-phase discovery, writes data/*.json, builds, commits, pushes):
./run.shrun.sh will: run the headless prompt → npm run validate (aborts on errors) →
npm run build → commit chore(events): weekly refresh <date> → git push.
Cloudflare Pages picks up the push and deploys.
The headless prompt discovers in two phases — Phase A sweeps every seed in
data/sources.json, then Phase B searches beyond the registry for what a venue
list structurally can't hold — and writes data/source_coverage.json alongside
the store. prompts/weekly.md step 3 has the phase rules and the off-registry
floor Phase B has to clear.
Heads up — direct push by design.
run.shcommits and pushes straight to the deploy branch because it's an automated content pipeline. That intentionally differs from the usual "open a PR, never push to main" workflow. If you'd rather gate deploys behind review, pointrun.shat acontentbranch and open PRs, or have Pages deploy from a preview branch.
Not installed automatically. See crontab.txt:
crontab -e
# paste (edit the path):
# 30 5 * * 6 /Users/cory/DooHub/run.sh >> /Users/cory/DooHub/run.log 2>&1
crontab -l # verifySaturday 05:30 local keeps the page fresh for the weekend. On macOS you may need
to grant your terminal / cron Full Disk Access for file writes, and confirm
claude/node/git resolve on cron's thin PATH (run.sh prepends the common bins).
After the first deploy, subscribe to:
https://<your-pages-domain>/events.ics
Apple Calendar / Google Calendar / Outlook can subscribe by URL. UIDs are stable
(<event-id>@triangle-weekend.cortech.online), so subscribers see updates rather
than duplicates across weeks. The site also offers client-side "All events",
"Filtered events", per-event, and per-itinerary .ics downloads.
| Command | What it does |
|---|---|
npm test |
Unit tests for the ics builder, validator, and dedup helpers |
npm run validate |
Schema/enum/window/dup-id checks on data/events.json; also data/sources.json (kebab-case ids, URL shape, parent_venue / venue_aliases anti-drift against dedup.ts) and data/source_coverage.json when present |
npm run validate:links |
Above plus HTTP 2xx checks on booking/info/image URLs and every registry source URL (fetch_blocked sources skipped) |
npm run build:ics |
Regenerate public/events.ics only |
npm run build |
Full deterministic build (validate → site → data → ics) |
npm run typecheck |
Type-check the scripts (site has its own npm --prefix site run typecheck) |
- Empty store:
data/events.jsonships empty ({ "events": [] }). The site shows sample data until the first real run publishes a non-empty store. - Empty registry:
data/sources.jsonseeds Phase A (48 sources today). An empty one validates with a warning, not an error — discovery still runs, it just has no floor. A missingdata/source_coverage.jsonis fine before the first run under the current prompt; a malformed one is an error. - Dedup: see
CLAUDE.md§ Dedup; the executable helpers live inscripts/lib/dedup.ts(computeId,isSameOccurrence). - Never commit secrets.
.env*and.dev.varsare gitignored; read any source API keys from the environment.