Skip to content

Architecture & deployment

Last updated: 2026-06-15

What it is

A solo, file-based research pipeline that turns Kyle's "I want to climb that peak" into a published, version-controlled, link-shareable research dossier. Optimized for low-ops (no servers Kyle runs), reproducibility (every artifact derives from inputs in iCloud + a few external APIs), and Claude-driven workflow (one conversation produces one full research cycle).

It is not an app. It is a static site fed by a local git clone, with scripts that pull from one private DB and several public web sources. The "runtime" is the terminal during a Claude session; the "deploy target" is Cloudflare Pages (mtn-research.pages.dev).

Two facts here changed in July 2026 and the old ones are still worth knowing, because they explain the shape of the repo. The site used to deploy to GitHub Pages — that is retired and dark. And the working copy used to live in iCloud Drive; it now lives at ~/dev/mtn_research, because a synced .git was clobbered by concurrent sessions. The GPX library travels in git now (source tracks are committed as of 2026-07-28), which is how a second Mac gets it — not iCloud.

Components

Layer Component Where it lives Role
Authoring runtime Claude session in mtn_research/ macOS terminal / Claude Code The agent that drives the cycle: queries DB, reads TRs, writes markdown, runs scripts, commits
Canonical methodology docs/methodology/*.md repo (published) Source of truth for how research is done: workflows, source rules, CalTopo pipeline, report template, conventions, improvement plan. Read first by any session.
Memory ~/.claude/projects/.../memory/*.md per-Mac local A thin bootstrap pointer to docs/methodology/ plus a few live references (peak_db, playwright-mcp, 14ers-required, user-location). If memory conflicts with the repo docs, the docs win.
Peak data — source of truth Supabase peak_checklist DB Supabase cloud ~861 peaks + ascents + list memberships. Read-only here. Kyle's climbed list.
Peak data — client peak_db_client.py ~/Library/Mobile Documents/com~apple~CloudDocs/shared/peak_db/ (iCloud) Stdlib-only Python module + the read key. Shared across all Claude projects.
External research sources (×3, all required) 14ers.com · listsofjohn.com · peakbagger.com (TRs, GPX, route/stat data, 14ers cell DB) public web, logged-in Driven via Playwright MCP (persistent profile, all 3 logged in). The Claude-in-Chrome extension is NOT used — it has an open allowlist bug.
Maps CalTopo (interactive research maps + per-range regional "GPS Tracks" maps) private maps Read via fetch_caltopo.py; write via gpx_to_caltopo.py / sync_to_regional.py; restyle via restyle_markers.py (caltopo_python API).
Repo (iCloud-synced) Projects/mtn_research/ iCloud Drive Single source for code, docs, raw GPX, CalTopo dumps. Cross-Mac via iCloud sync.
Tooling scripts/*.py repo, in scripts/ uv inline-deps for portable scripts; stdlib for the simple ones
Inputs gpx/<slug>/ source tracks — IN GIT since 2026-07-28 (repo is private); derived GPX, caltopo/<map_id>.json, notes/ stay gitignored local clone ~/dev/mtn_research The swept beta travels in git now — that is how a second Mac gets it, not iCloud. caltopo/ is a re-fetchable cache (84 map dumps, ~937 MB, one file 291 MB — too big and too churny to commit, and CalTopo itself is authoritative). Credentials live OUTSIDE the repo at ~/Library/Mobile Documents/com~apple~CloudDocs/mtn_research_data/cts.ini, symlinked in as cts.ini.
Outputs (versioned) docs/**, climbers/*.yml, mkdocs.yml, overrides/, scripts repo, in git The publishable artifacts + per-climber profiles + scripts.
CI/CD .github/workflows/docs.yml repo On push to main: lint (source-rigor gate, check_reports.py) → deploy (scripts/deploy_site.py → Cloudflare Pages). Lint failure blocks the deploy.
Hosting Cloudflare Pages mtn-research.pages.dev Free, custom-domain-capable, static-only. Sibling of the mtn-share shares project (same account/wrangler auth). Doesn't require the repo to be public.
Source repo GitHub kylegknutson/mtn-research github.com/kylegknutson/mtn-research Private (post-CF-migration); carries the gpx data so a clone is self-sufficient.

Deployment diagram

┌──────────────────────────────────────────────────────────────────────────────┐
│                         CLAUDE SESSION (any Kyle Mac)                         │
│  ┌─────────────────────────────────────────────────────────────────────────┐ │
│  │                       Claude (Sonnet, Code CLI)                          │ │
│  │   ▲                                                                      │ │
│  │   │ reads on every session start                                         │ │
│  │   │                                                                      │ │
│  │   ├─── memory bootstrap → docs/methodology/ (canonical workflow docs)    │ │
│  │   │                                                                      │ │
│  │   ├─── Playwright MCP (persistent profile; 14ers + LoJ + peakbagger      │ │
│  │   │       all logged in)  ·  Chrome extension unused (allowlist bug)     │ │
│  │   │                                                                      │ │
│  │   └─── Bash / Edit / Write / Grep / Read tools                           │ │
│  └─────────────────────────────────────────────────────────────────────────┘ │
└────┬────────────────────────────────────────────────────────────────────────┬─┘
     │                                                                        │
     │  query peaks/ascents (read-only)                                       │
     ▼                                                                        │
┌──────────────────────────────────┐                                          │
│   ~/Library/.../shared/peak_db/  │   (iCloud-synced, NOT in repo)           │
│   ─────────────────────────────  │                                          │
│   peak_db_client.py  (stdlib)    │                                          │
│   peak_checklist.env (secret_key)│──┐                                       │
└──────────────────────────────────┘  │                                       │
                                      │ HTTPS / PostgREST                     │
                                      ▼                                       │
                       ┌──────────────────────────────┐                       │
                       │   Supabase (peak_checklist)  │                       │
                       │   ───────────────────────    │                       │
                       │   peaks (861)  ascents (591) │                       │
                       │   peak_lists (946)           │                       │
                       │   ← written by separate      │                       │
                       │     peak_checklist webapp    │                       │
                       └──────────────────────────────┘                       │
                                                                              │
                       ┌──────────────────────────────┐                       │
     ┌─────────────── │   Public web (HTTPS)         │ ──────────────────────┤
     │                │   ───────────────────────    │                       │
     │  Playwright    │   • 14ers · LoJ · peakbagger │   CalTopo (research +  │
     │  MCP (logged   │      (TRs, GPX, stats, cell) │   regional maps,       │
     │  in ×3)        │   • Google Maps (drive time) │   caltopo_python API)  │
     │  ◀────────────│   • CalTopo private maps     │ ─────────────────────▶ │
     │                └──────────────────────────────┘                        │
     │                                                                        │
     ▼                                                                        ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                  REPO: ~/Library/.../Projects/mtn_research/                  │
│                          (iCloud-synced; git-tracked)                        │
│                                                                              │
│   ┌──────── gitignored (personal data) ────────┐  ┌──── tracked in git ───┐ │
│   │                                             │  │                       │ │
│   │  gpx/<slug>/*.gpx                           │  │  docs/peaks/*.md      │ │
│   │  caltopo/<map_id>.json                      │  │  docs/maps/*.png      │ │
│   │  scripts/cts.ini                            │  │  scripts/*.py         │ │
│   │  notes/*.md (WIP planning)                  │  │  mkdocs.yml           │ │
│   │                                             │  │  README.md            │ │
│   └─────────────────────────────────────────────┘  └───────────────────────┘ │
│                                                                              │
│                      scripts/ (uv inline-deps, portable)                     │
│   ┌─────────────────────┬──────────────────────┬──────────────────────────┐ │
│   │ make_overview_map   │ fetch_caltopo        │ find_nearby              │ │
│   │ (Pillow+OSM tiles,  │ gpx_to_caltopo       │ combo_stats              │ │
│   │  source-colored)    │ sync_to_regional     │ drive_time               │ │
│   │ caltopo_to_gpx      │ restyle_markers      │ check_reports (CI gate)  │ │
│   │ build_*_gpx         │ delete_caltopo_marker│ (all caltopo_python/std) │ │
│   └─────────────────────┴──────────────────────┴──────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────────────────┘
                               │
                               │  git push origin main  (via gh auth)
                               ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                         GitHub: kylegknutson/mtn-research                    │
│                                                                              │
│   ┌──────────────────────┐         ┌──────────────────────────────────────┐ │
│   │ main branch          │ ─────▶  │ GitHub Actions: docs.yml             │ │
│   │ (markdown, PNGs,     │         │   - lint: check_reports.py (gate)    │ │
│   │  scripts, mkdocs.yml)│         │   - build: mkdocs                    │ │
│   └──────────────────────┘         │   - deploy: Pages artifact           │ │
│                                    └─────────────┬────────────────────────┘ │
│                                                  ▼                           │
│                                    ┌──────────────────────────────────────┐ │
│                                    │ gh-pages branch (static HTML)        │ │
│                                    └─────────────┬────────────────────────┘ │
└──────────────────────────────────────────────────┼──────────────────────────┘
                                                   │
                                                   ▼
                            ┌──────────────────────────────────────────┐
                            │  Cloudflare Pages CDN                     │
                            │  mtn-research.pages.dev                   │
                            │                                           │
                            │  Reader (Kyle's iPhone, friends, etc.)   │
                            └──────────────────────────────────────────┘

Legend:

  • Solid lines: same-machine / same-trust boundary
  • Arrows across the boundary: network hop with auth (token / cookie / key)

The orchestrated build — sequence

Every report build/rebuild runs through build_report.py (2026-08; renamed from rebuild_report.py — one tool builds and rebuilds, a fresh slug just makes quarantine a no-op). Claude only ever executes the orchestrator; the guarded build scripts refuse any caller without the orchestrator's run token. Kyle's decisions enter as data (deviations.yml) and persist across rebuilds.

sequenceDiagram
    autonumber
    participant K as Kyle
    participant C as Claude
    participant O as build_report.py<br/>(orchestrator)
    participant S as guarded build scripts
    participant G as gates
    participant X as sources · CalTopo · Pages

    C->>O: --start slug
    O->>S: preflight.py
    O->>O: quarantine old artifacts (outside repo) + strip nav + seed peaks.yml
    Note over O: run token minted · status.html goes live
    O-->>C: stop — sweep needed
    C->>X: 3-source browser sweep (login-gated, false-empties refused)
    C->>O: --advance
    O->>O: verify sweep freshness + sources.json
    O->>S: scaffold --force · build_peak_gpx · find_osm_trailheads → evidence
    O->>O: re-apply Kyle's deviations (materialize)
    O-->>C: stop — research (fill peaks.yml: TH, days/recipe, research:)
    C->>O: --advance
    O->>S: once-per-run prep: OSM sweeps · compose --why → evidence
    O->>S: compose --apply (skipped for from_track and from-camp days)
    O->>S: build_route / build_trip_day_routes (+ leg corridors)
    O->>G: route gates (recipe · fidelity · composition · seasons)
    O->>S: build_report_steps (data, captured) — new CalTopo map minted
    O->>S: write_report.py → skeleton with NARRATIVE-TODO markers
    O-->>C: stop — prose (replace markers, Edit tool only)
    opt a research decision changes (Kyle's verdict · composer evidence · a defect fix)
        C->>O: --rewind (Claude/Kyle only — refused once the publish commit lands)
        Note over O: phase → research · route prep + downstream stamps cleared<br/>status page banners the rewind · pre-rewind time stays in the total
        C->>O: --advance — route/data re-run · a TODO-marked skeleton is regenerated,<br/>finished prose is never overwritten
    end
    C->>O: --advance
    O->>G: prose gates (surviving markers refused) · finalize: all gates
    O->>X: ONE atomic commit + push · deploy_site · verify deploy
    O-->>C: stop — at finish
    C->>O: --finish
    O->>X: delete old + superseded CalTopo maps · remove quarantine
    O->>O: build-log entry (timing · issues · mid-run gate changes)
    K-->>C: verdicts on published review maps (markers, groupings, strays)
    C->>O: recorded as deviations.yml — carried into every future rebuild

Any gate failure stops --advance with the problem verbatim (also on the live status page). Rewind is not error-handling — a failed gate means bad data (fix the inputs) or a code bug (fix the script, add a test). --rewind exists for one case: a research-phase judgment changes after later evidence — Kyle rejects a grouping/marker, the composer's evidence beats the recipe Claude picked, or a defect fix invalidates built artifacts. The trigger can surface anywhere, but the call is always Claude's or Kyle's (the orchestrator never rewinds itself), the target is always research (where every judgment input lives), and it is refused after the publish commit — post-publish changes are a new run. --abort restores the quarantined report byte-identically. The numbered list below predates the orchestrator and remains as the underlying mechanics reference.

One research cycle, step by step

Fast path (low-prompt): preflight.py (flight check: resolve peaks, creds, login indicators → GO/NO-GO) → in-chat GPX sweep + ingest_gpx.pyscaffold_report.py (dir + peaks.yml) → build_report.py (one command: waypoints → CalTopo cross-ref → combo stats → drive → PNG → CalTopo map → regional sync) → write prose → build_report.py --finalize (climber-status + index + QA gates) → commit → push (the one deliberate publish gate). The mechanical work lives inside these allowlisted scripts, so a build has two human touch points: approve the flight-check, approve the publish. See caltopo-pipeline §Fast path. The numbered steps below are the detailed reference the fast path chains.

  1. Kyle: "Find me unclimbed Sangre 13ers under 4,000 ft / 8 mi" (narrow-down) or "do a report on Peak X" (report).
  2. Claude reads docs/methodology/ (pointed to by the memory bootstrap) → knows the workflow, source rules, pipeline, conventions.
  3. Login check — confirm all 3 sources logged in via Playwright (per source-requirements). No 14ers → halt.
  4. DB query via peak_db_client.peaks(...) + ascents() → candidates; cluster analysis (nearby unclimbed ranked, same-drainage flag).
  5. TR + GPX sweep across all 3 sources (14ers + LoJ + peakbagger) via Playwright → route stats, combo signal, every GPX track.
  6. Stats / cell / drivecombo_stats.py (distance/gain), 14ers cell DB, drive_time.py (Maps link from the climber's home). Headline distance/gain comes from the measured GPX, never climb13ers prose (see source-requirements.md); check_route_stats.py audits this.
  7. Authoringdocs/peaks/<slug>.md (or docs/trips/ for multi-day) per the report template, ending with the "Sources checked" footer.
  8. Maps (required, ship with the report):
  9. gpx_to_caltopo.py --gpx-dir gpx/<slug> --new-map "Research: …" --no-dedupe → research map (summit=blue peak, others=gray point, tracks source-colored).
  10. sync_to_regional.py --slug <slug> --map-id <regional> → also push tracks into the range's regional "GPS Tracks" map.
  11. build_recommended_route.py <slug> (optional, multi-track peaks) → gpx/<slug>/<slug>_recommended.gpx: shortest route through only the ranked objectives (add-on peaks trimmed), via a pooled-track graph router that splices real segments across parties' tracks at crossings, either direction (--legs for the older per-leg method); distance from the route GPX, gain resampled from a DEM (matches CalTopo — GPX <ele> is too noisy). Renders as the bold-magenta "recommended route (composed)" line.
    • Teleport guard (Kyle caught one by eye 2026-06-15). The graph router can shortcut a long approach with a multi-mile straight-line jump (cheaper than following the trail) — invisible to the pixel gates. check_route_geometry.py flags any within-segment consecutive-point jump > 1 mi as a FAIL (wired into --finalize). Fix a flagged route with --legs, or split genuinely-disconnected objectives into separate <trkseg>s (e.g. two individual climbs that share no recorded connector — the gate checks within a segment, so deliberate breaks are allowed and never invent a straight-line connector across terrain no party recorded).
  12. make_overview_map.py <slug>docs/maps/<slug>.png (source-colored, objective-framed; magenta recommended route on top if present).
  13. Wire up → map IDs + PNG into the markdown; image: frontmatter for the link preview; mkdocs nav.
  14. Structured stat frontmatter (dist_mi, gain_ft, class, peaks, days, drive_h, and days_detail for trips) is the single source of truth for the stat surfaces. gen_quickstats.py renders the top "At a glance" callout; gen_index.py emits the sortable index table (numeric columns + tablesort) and docs/data/report_stats.json, which nav-stats.js reads to append a compact badge under each report's left-nav link. All three have --check lints.
  15. Commit + push → Actions runs lint (source gate) → build → deploy; Pages updates in ~1 min. Don't mark "researched" until the deploy is green.
  16. Result: a public URL with a link-preview card, openable from the phone at the trailhead, plus interactive CalTopo research + regional maps.

Concepts added since the original design

  • Methodology is canonical and in-repo. docs/methodology/ (workflows, source-requirements, caltopo-pipeline, report-template, conventions, improvement-plan) is the source of truth. Memory is a thin pointer. → reproducible on any Mac, no chat history.
  • Three sources, always. 14ers + LoJ + peakbagger must each be logged-in and checked; a "Sources checked" footer asserts it and the CI lint gate enforces it.
  • Browser = Playwright MCP (persistent profile), not the Claude-in-Chrome extension (open allowlist bug).
  • CalTopo: research + regional maps. Each report has a focused research map; tracks are also synced into a per-range regional "GPS Tracks" map (registry in caltopo-pipeline.md). Marker scheme: summit = blue peak (#2E78C7), everything else = gray point (#9E9E9E); tracks colored by source.
  • Multi-climber. climbers/<slug>.yml holds home address (drive-time origin) + climbed-list source (peak_db for Kyle, 14ers checklist for friends). Friends get per-(peak,climber) reports and their own published site. (Scaffolded; not yet exercised end-to-end.)
  • Report flavors. Single-peak · day-trip (multi-peak, one outing) · multi-day backpack (docs/trips/).
  • Narrow-downs as artifacts. Saved to docs/lists/ (snapshot + re-runnable criteria) rather than chat-only. (Planned — see improvement-plan.)
  • Site polish. Flatirons favicon + home logo; per-report Open Graph link previews (own overview map as the card) via overrides/main.html; external links open in a new tab.
  • Interactive peak map (home + /peak-map/). scripts/gen_peak_map.py reads every ranked Colorado peak from peak_db, plus the climb log (ascents()), and assigns each a status: report (via gpx/<slug>/peaks.yml objective_ids or a peak_ids: frontmatter fallback) → climbedto-do. Writes docs/data/peaks.json (committed — the static deploy serves it; CI never touches peak_db). A Leaflet map (docs/javascripts/peak-map.js, CDN lib in mkdocs.yml; paths resolved from the script URL so it survives Material instant-nav) draws reported peaks as a green CalTopo-style peak icon (#39FF14, click → report), climbed as grey dots, to-do as red dots, on toggleable layers (canvas-rendered for speed). Regenerated in build_report --finalize.
  • Climbed-status refresh (peak_db → site). Climbed status is a build-time snapshot, not live — peak_db powers it but the site only changes when the derived outputs are regenerated and pushed. scripts/refresh_from_peakdb.py is the one-command pipeline for this: gen_peak_map.py (map data) → sync_status.py (report frontmatter status: ← whether all its objective_ids are climbed; drives the index Status column) → gen_index.py (table + nav badges), then commits + pushes the changed outputs (→ Pages redeploys). It's idempotent and meant to run on a Mac as the post-processing step after the peak-checklist app updates peak_db (the Mac already has the client + key; no GitHub secret or cloud trigger needed). --dry-run / --no-push available. (The report body's prose "Status in DB:" line is editorial and stays manual.)

What's reproducible vs. authoritative

Artifact Source of truth Regeneratable?
peaks / ascents rows Supabase (written by separate peak_checklist webapp) No — owned upstream
Downloaded GPX in gpx/<slug>/ 14ers + LoJ + peakbagger (logged-in, via Playwright) Yes — re-sweep all 3 sources
CalTopo JSON dumps in caltopo/ Kyle's CalTopo account Yes — fetch_caltopo.py
Overview PNGs in docs/maps/ derived from gpx/<slug>/ + OSM tiles Yes — make_overview_map.py
Markdown in docs/peaks/ Claude + Kyle, version-controlled in git Authoritative — this is the work product
Published mkdocs site derived from markdown via Actions Yes — git push triggers rebuild
CalTopo research maps (cloud) Kyle's CalTopo account, written by gpx_to_caltopo.py Yes — re-run script with same GPX

The research markdown is the only thing that isn't trivially regenerable. Everything else is a derived artifact or an upstream mirror. That's the key property — losing the laptop costs nothing if iCloud has synced (and even iCloud loss is fine if git has the markdown).

Trust & secrets

Secret Where Risk if leaked
SUPABASE_SECRET_KEY (peak_db) iCloud-only shared/peak_db/peak_checklist.env Full RW on Kyle's climb DB. Never in git, never in chat. Rotate via Supabase dashboard.
CalTopo credentials scripts/cts.ini (gitignored) Full account access to maps.
GitHub PAT gh keyring (system) Repo push access. Per-Mac via gh auth login.

Everything else (research markdown, public GPX, OSM tiles, etc.) is public-by-design.

Why this stack

  • No server Kyle runs. Supabase + Cloudflare Pages + a local git clone. The only thing maintained is the markdown and scripts.
  • Multi-Mac portability solved twice. iCloud sync handles the repo and personal data; uv + gh handle the per-Mac toolchain (one brew install apiece, then nothing to remember).
  • Claude is in the loop, not a one-shot. Memory files mean every session starts informed. The workflow recipes are versioned outside the project repo (in ~/.claude/projects/.../memory/), so they evolve with the agent.
  • Public output, private inputs. .gitignore cleanly splits "research notes for the world" from "Kyle's personal GPX + CalTopo data." Same repo, two audiences.

This page is regenerated whenever the architecture changes — scripts added/removed, deployment target moves, secrets rotated, etc. Update the "Last updated" date at the top when editing.