Agentic Cloudflare Pipeline
How I built & deployed Mother's Day 2026 in one session โ and what to install so future me can do it without asking you.
1 ยท Executive summary
Tonight's project: a private Mother's Day site for four moms in your life โ Cynthia, Mama Jean, Julia, Aunt Carol โ with 122 family photos, a multi-tag filter, two re-rendered 2023 cards from Evernote, a Suno song lyrics card with one-click generation, a t-shirt mini-game, and a password gate.
Live at mothers-day-2026-11v.pages.dev, password bestmomintheworld. Custom domain mom.loudog.uno is bound on Cloudflare Pages but awaiting the DNS CNAME โ the OAuth token from wrangler login does not include zone:dns:edit.
A repeatable, mostly-autonomous pipeline: Apple Photos sqlite โ derivative copy โ manifest โ static-site render โ Cloudflare Pages deploy with Function-based password gate.
One install (Cloudflare API token with Zone DNS ยท Edit + Pages ยท Edit) removes 100% of the "I need you to click this" moments in future sessions. See ยง5.
2 ยท What I built tonight
| Component | Tech | Notes |
|---|---|---|
| Single-page site | Vanilla HTML + CSS columns + JS | No framework. 38KB index.html. |
| 122 photo collage | JSON manifest โ JS render โ CSS column masonry | Lazy-loaded, 38MB total. |
| 4-mom filter | Space-separated data-moms attribute + JS Array.includes | Multi-tag UNION (Cynthia+Jean photo shows under both). |
| 2023 cards reprise | PNGs base64-extracted from .enex archives | Real cards you printed in 2023. |
| Suno song card | Embedded lyrics + clipboard-copy button + suno.com tab opener | Drop MP3 at assets/audio/mom-2026.mp3 and <audio> auto-shows. |
| T-shirt mini-game | 4 hidden ๐ฝ elements + click handlers + confetti | One per mom's section. |
| Password gate | Cloudflare Pages Function (functions/_middleware.js) | HTTP-only cookie, 60-day persistence. |
| Soft privacy | robots.txt Disallow + middleware gate | Not indexed, not crawlable. |
3 ยท The pipeline (6 steps)
Here's the full sequence I ran tonight, with the actual queries. This is the template for any "family/personal photo site" agentic build.
Photo discovery โ Apple Photos sqlite
The Photos library has a sqlite DB at a fixed path with face-recognition tables.
# Database path DB=~/Pictures/Photos\ Library.photoslibrary/database/Photos.sqlite # Find person clusters by name (multiple "Cynthia" clusters are common) sqlite3 $DB "SELECT p.Z_PK, p.ZFULLNAME, COUNT(DISTINCT f.ZASSETFORFACE) as photos FROM ZPERSON p JOIN ZDETECTEDFACE f ON f.ZPERSONFORFACE = p.Z_PK JOIN ZASSET a ON a.Z_PK = f.ZASSETFORFACE WHERE p.ZFULLNAME LIKE '%Cynthia%' AND a.ZTRASHEDSTATE = 0 GROUP BY p.Z_PK HAVING photos > 5 ORDER BY photos DESC"
Key tables and joins:
ZPERSONโ face clusters Apple has named or you've tagged.Z_PK= person id.ZDETECTEDFACEโ every face Apple detected.ZPERSONFORFACEโ person,ZASSETFORFACEโ photo.ZASSETโ the actual photo/video.ZKIND0=photo, 1=video.ZTRASHEDSTATE0=alive.
Apple's ML-ranked quality columns on ZASSET (huge for picking "the good ones" automatically):
ZOVERALLAESTHETICSCOREยท 0.0โ1.0 ยท the master beauty scoreZCURATIONSCOREยท how often Apple includes it in MemoriesZICONICSCOREยท how prominent the face is (faces only)ZFAVORITEยท 1 if you tapped heartZLATITUDE/ZLONGITUDEยท GPS, -180 = unknownZDATECREATEDยท seconds since 2001-01-01 (add978307200to get unix epoch)
Asset materialization โ bypass iCloud "Optimize Mac Storage"
Originals are often not on disk (iCloud-resident). But Apple keeps two on-disk variants in resources/derivatives/ that are perfect for web:
# Originals (often missing in iCloud-optimize mode) ~/Pictures/Photos\ Library.photoslibrary/originals/{0-F}/{UUID}.{ext} # Web-friendly preview ~1024px (always local) โ USE THIS ~/Pictures/Photos\ Library.photoslibrary/resources/derivatives/masters/{0-F}/{UUID}_*.jpeg # Tiny thumbnail (fallback) ~/Pictures/Photos\ Library.photoslibrary/resources/derivatives/{0-F}/{UUID}_*.jpeg
Python copy with multi-tag aggregation:
import sqlite3, shutil, glob, os, json # Get every photo containing at least one of 4 target person Z_PKs, # aggregating ALL persons in each photo so the filter can do union logic q = """ SELECT a.Z_PK, a.ZUUID, datetime(a.ZDATECREATED+978307200, 'unixepoch'), a.ZLATITUDE, a.ZLONGITUDE, a.ZOVERALLAESTHETICSCORE, a.ZFAVORITE, GROUP_CONCAT(DISTINCT f.ZPERSONFORFACE) as person_pks FROM ZASSET a JOIN ZDETECTEDFACE f ON f.ZASSETFORFACE = a.Z_PK WHERE f.ZPERSONFORFACE IN (5903,26988,5858,5944) AND a.ZTRASHEDSTATE = 0 AND a.ZKIND = 0 GROUP BY a.Z_PK """ # ... then copy largest derivative for each UUID
Evernote .enex extraction (bonus visual gifts)
Evernote exports are XML with base64-embedded resources. Tonight, three .enex files contained the actual 2023 Mother's Day cards as PNGs:
import re, base64 with open(enex_path) as f: txt = f.read() resources = re.findall(r'<resource>(.*?)</resource>', txt, re.S) for r in resources: mime = re.search(r'<mime>([^<]+)', r).group(1) if 'image' not in mime: continue raw = base64.b64decode(re.search(r'<data[^>]*>(.*?)</data>', r, re.S).group(1).replace('\n','')) open(out_path, 'wb').write(raw)
Static site build โ single index.html + JSON-driven render
Three files do everything:
index.htmlโ semantic structure, embedded CSS (Playfair Display via Google Fonts), inline JS at endassets/photos-manifest.jsonโ array of{ file, taken, moms[], lat, lng, score, favorite }assets/photos/all/*.jpegโ the copied derivatives
Filter logic โ the cleanest multi-tag pattern I know:
// On each photo cell: cell.dataset.moms = p.moms.join(" "); // "cynthia jean" // On chip click: chip.addEventListener("click", () => { activeFilter = chip.dataset.mom; document.querySelectorAll(".cell").forEach(cell => { const moms = cell.dataset.moms.split(" "); cell.classList.toggle("hidden", activeFilter !== "all" && !moms.includes(activeFilter)); }); });
Deploy to Cloudflare Pages
Authentication once via OAuth (one-time, browser popup):
npx wrangler login
Then create the project (only once per site) + deploy:
# Project create โ gives you the *.pages.dev domain (unset CF_API_TOKEN; unset CLOUDFLARE_API_TOKEN; npx wrangler pages project create mothers-day-2026 --production-branch=main) # Deploy (unset CF_API_TOKEN; unset CLOUDFLARE_API_TOKEN; npx wrangler pages deploy dist \ --project-name=mothers-day-2026 \ --commit-dirty=true \ --branch=main)
Your Cloudflare skill at ~/.claude/skills/Cloudflare/SKILL.md says: "ALL env tokens lack Pages permissions. MUST unset them to use OAuth." This is true if your existing token is narrow. With the broad token described in ยง5, the unset isn't needed.
Password gate via Pages Function
Cloudflare Pages auto-bundles any code under ./functions/ (sibling of the deploy dir, NOT inside it):
// functions/_middleware.js โ runs on every request export const onRequest = async (context) => { const { request, next } = context; const url = new URL(request.url); if (url.pathname === "/login") return handleLogin(request); const cookieHdr = request.headers.get("Cookie") || ""; const authed = cookieHdr.split(";").some(c => c.trim() === "mday2026=you-are-welcome-here"); if (authed) return next(); return new Response(loginHtml(false), { headers: { "content-type": "text/html; charset=utf-8" } }); };
Cookie-based "passwords" are soft auth โ fine to keep randoms out, not real security. If you need real auth, Cloudflare Access (free for personal accounts) is the upgrade.
4 ยท Where I hit friction
resources/derivatives/masters/ instead โ locally cached ~1024px JPEGs, fine for web. (Full-res only needed if you're printing.)wrangler pages has no domain subcommandPOST /accounts/{id}/pages/projects/{name}/domains with body { "name": "mom.loudog.uno" }. Worked with OAuth bearer.zone:read only, no zone:dns:editZone DNS ยท Edit permission (see ยง5, install A). With it, future-me creates CNAMEs autonomously.functions/ must be a sibling of the deploy directory, NOT inside it. ./functions/_middleware.js + ./dist/ โ run wrangler pages deploy dist from the parent.\' in nested strings. Use backticks all the way down, or rephrase to remove apostrophes ("was not" instead of "wasn't")..jpeg when copying.5 ยท To make this fully agentic โ install checklist
Three installs ranked by ROI. Just A removes 95% of the "I need you to click X" moments in future sessions. A + B unlocks full autonomy for any Cloudflare operation.
One long-lived API token with everything an agent might need. Stored in your shell env so wrangler picks it up automatically.
Create the token
- Go to https://dash.cloudflare.com/profile/api-tokens
- Click Create Token โ Create Custom Token
- Name:
agent-full-access - Permissions (add all):
- Account ยท Cloudflare Pages ยท Edit
- Account ยท Workers Scripts ยท Edit
- Account ยท Workers KV Storage ยท Edit
- Account ยท Workers R2 Storage ยท Edit
- Account ยท D1 ยท Edit
- Account ยท Workers Routes ยท Edit
- Zone ยท DNS ยท Edit โ the one tonight was missing
- Zone ยท SSL and Certificates ยท Edit
- Zone ยท Zone Settings ยท Edit
- Zone ยท Zone ยท Read
- Account Resources: Include โ All accounts (or specific)
- Zone Resources: Include โ All zones from an account (or specific)
- TTL: optional. Recommend none (long-lived).
- Continue โ Create Token โ copy.
Install it
Add to ~/.zshrc (or wherever you keep secrets):
export CLOUDFLARE_API_TOKEN="" export CF_ACCOUNT_ID="19c78fe31f69a628914ac079d1c6a7c2" # your account id
Then source ~/.zshrc. Verify:
npx wrangler whoami # should say "logged in via API Token" curl -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ https://api.cloudflare.com/client/v4/user/tokens/verify
I can: create Pages projects + deploy + add custom domains + create DNS records + manage R2 buckets + Workers + D1 โ all without your involvement. The full pipeline becomes one command from end to end.
Treat this token like an SSH key. Don't commit it. Put it in ~/.zshrc (or better, ~/.config/cloudflare/token with chmod 600 and source from .zshrc). If it ever leaks, rotate it from the dashboard.
Cloudflare publishes an official MCP server. Once registered, future Claude sessions get structured tools like cloudflare_pages_deploy and cloudflare_dns_create_record instead of constructing curl + json by hand.
Install + register
# Package: @cloudflare/mcp-server-cloudflare (v0.2.0) # Github: https://github.com/cloudflare/mcp-server-cloudflare # Add to ~/.claude.json or settings.json mcpServers section: { "mcpServers": { "cloudflare": { "command": "npx", "args": ["-y", "@cloudflare/mcp-server-cloudflare"], "env": { "CLOUDFLARE_API_TOKEN": "" , "CLOUDFLARE_ACCOUNT_ID": "19c78fe31f69a628914ac079d1c6a7c2" } } } }
Restart Claude Code. The next session will see mcp__cloudflare__* tools available.
Without it, I write 6-line curl invocations every time. With it, I call typed tools and the auth/account-id/error-handling is invisible.
Your ~/.claude/skills/Cloudflare/ already has Create + Troubleshoot workflows. Worth adding two new workflow files:
Workflows/DeployPagesSite.mdโ the exact 6-step pipeline above as a reusable skillWorkflows/PasswordGate.mdโ the_middleware.jstemplate + how to choose between cookie-gate vs Cloudflare Access
I can write both for you next session if you want โ just say "extend the Cloudflare skill with tonight's patterns."
Quick recommendation
Do A. That's it. Future agentic sessions will then do every Cloudflare operation end-to-end without asking you for anything. B is nice-to-have polish. C is institutional memory for the long run.
6 ยท Repeatable patterns (templates)
Pattern A ยท Family-photo site (Apple Photos โ web)
- Query
ZPERSONfor person clusters by name โ get Z_PK list - Query
ZASSETjoined withZDETECTEDFACE, aggregatingGROUP_CONCATof all persons per asset, sorted byZOVERALLAESTHETICSCORE - Copy derivatives from
resources/derivatives/masters/to project - Emit a JSON manifest with file paths + multi-person tags
- Render with vanilla JS, CSS columns for masonry, data-attrs for filtering
- Deploy to Cloudflare Pages
Pattern B ยท Soft-private static site
functions/_middleware.jswith cookie checkrobots.txtwithDisallow: /<meta name="robots" content="noindex, nofollow">on the login page- Optional: graduate to Cloudflare Access for email-OTP gate
Pattern C ยท Custom domain on Cloudflare Pages
POST /accounts/{id}/pages/projects/{name}/domainswith{name: "sub.domain.tld"}- Create CNAME on the zone:
POST /zones/{id}/dns_recordswith type CNAME, content ={project}.pages.dev, proxied = true - Wait ~30 sec for cert to issue (auto)
Pattern D ยท iMessage / Notes / Bear intel for project context
- Messages:
~/Library/Messages/chat.db - Notes:
~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite - Bear:
~/Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite - All need Full Disk Access TCC permission for Terminal / Claude โ granted on this Mac already.
Pattern E ยท Evernote .enex extraction
XML with base64 resources. <resource> blocks contain <mime> + <data> base64 โ decode and write to disk.
7 ยท Appendix ยท the actual command log
Click to expand the full chronological command sequence
# 1. Verify TCC access to private databases sqlite3 ~/Pictures/Photos\ Library.photoslibrary/database/Photos.sqlite "SELECT count(*) FROM ZPERSON" sqlite3 ~/Library/Messages/chat.db "SELECT count(*) FROM message" # 2. Identify person clusters sqlite3 $DB "SELECT Z_PK,ZFULLNAME,... FROM ZPERSON JOIN ZDETECTEDFACE..." # Cynthia=5903, Jean=26988, Julia=5858, Carol=5944 # 3. Bulk-pull + multi-tag photos (Python) python3 build-photo-manifest.py # 4. Extract .enex card PNGs python3 extract-enex-resources.py # 5. Build dist/ mkdir -p dist/assets/photos/all dist/assets/cards cp index.html dist/ cp assets/photos-manifest.json dist/assets/ cp assets/photos/all/*.jpeg dist/assets/photos/all/ cp assets/cards/*.png dist/assets/cards/ # 6. Cloudflare auth (one-time) npx wrangler login # 7. Create Pages project (unset CF_API_TOKEN; unset CLOUDFLARE_API_TOKEN; npx wrangler pages project create mothers-day-2026 --production-branch=main) # 8. Deploy (unset CF_API_TOKEN; unset CLOUDFLARE_API_TOKEN; npx wrangler pages deploy dist --project-name=mothers-day-2026 --commit-dirty=true --branch=main) # => https://mothers-day-2026-11v.pages.dev # 9. Register custom domain via API (OAuth-authed) TOKEN=$(grep oauth_token ~/Library/Preferences/.wrangler/config/default.toml | sed ...) curl -X POST -H "Authorization: Bearer $TOKEN" \ https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/pages/projects/mothers-day-2026/domains \ -d '{"name":"mom.loudog.uno"}' # 10. Create CNAME โ BLOCKED (zone:read scope only) # Would have been: # curl -X POST .../zones/$ZONE_ID/dns_records -d '{"type":"CNAME","name":"mom",...}' # 11. Add password gate cat > functions/_middleware.js <<EOF ... cookie-check middleware ... EOF # 12. Redeploy with Function npx wrangler pages deploy dist --project-name=mothers-day-2026 ... # 13. Verify curl -sI https://mothers-day-2026-11v.pages.dev # => login page curl -b "mday2026=you-are-welcome-here" https://mothers-day-2026-11v.pages.dev # => real site
Closing
The core pipeline is now repeatable in ~5 minutes for any future personal/family/portfolio site:
build dist/(whatever assets + index)wrangler pages project create(if first deploy)wrangler pages deploy dist- (if private) drop in
functions/_middleware.js+ redeploy - (if custom domain) register + create CNAME
Install A from ยง5, and steps 4โ5 become fully autonomous. Then "spin up a private password-gated site at X.loudog.uno" becomes one sentence and a few minutes.
Document built 2026-05-11 ยท for Lou DeSantis ยท by Sai (claude-opus-4-7).
Source pipeline: /Users/loudog/Library/Mobile Documents/com~apple~CloudDocs/CloudClaude/projects/mother's day