Imported from superhighfives/records.charliegleason.com (
AGENTS.md). Install upstream withnpx skills add superhighfives/records.charliegleason.com. Copyright stays with the author.
Skill Loading
Before substantial work:
- Skill check: run
npx @tanstack/intent@latest list, or use skills already listed in context. - Skill guidance: if one local skill clearly matches the task, run
npx @tanstack/intent@latest load <package>#<skill>and follow the returnedSKILL.md. - Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
records.charliegleason.com
A React dashboard that catalogs a personal vinyl collection, deployed on Cloudflare at records.charliegleason.com, with an admin interface behind Clerk at /admin.
Scaffolding provenance
Scaffolded with the TanStack CLI (Create TanStack App). The canonical command for the chosen stack is:
npx @tanstack/cli@latest create records --agent --deployment cloudflare \
--add-ons form,shadcn,table,tanstack-query,sentry,clerk,prisma,neon,ai,store
Deliberate deviation from the literal command: prisma and neon were dropped in
favour of drizzle + db, because the deployment target is Cloudflare D1, and
Drizzle (not Prisma) is the ORM with first-class D1 support. The realised add-on set
(see .cta.json) is: biome, cloudflare, clerk, sentry, ai, db, drizzle, form, mcp, shadcn, table, store, storybook, tanstack-query.
Follow-up TanStack Intent commands
npx @tanstack/intent@latest install # created the intent-skills block above
npx @tanstack/intent@latest list # 13 intent-enabled packages, 51 skills
Always run intent list / intent load <pkg>#<skill> before making library-specific
changes (TanStack AI, DB, Start, Router all ship skills) instead of guessing patterns.
Stack & integrations
| Concern | Choice |
|---|---|
| Framework | TanStack Start (SSR) + TanStack Router (file-based routes) |
| Data fetching | TanStack Query + Router loaders; TanStack DB collections |
| Tables / filters | TanStack Table |
| Forms | TanStack Form |
| State | TanStack Store |
| AI | TanStack AI (@tanstack/ai*) → Cloudflare Workers AI / AI Gateway |
| Auth | Clerk (@clerk/clerk-react), admin gated at /admin |
| Error monitoring | Sentry — Worker via @sentry/cloudflare withSentry (apps/web/src/server.ts), browser via init in apps/web/src/client.tsx, source maps via sentryTanstackStart Vite plugin |
| Database | Drizzle ORM → Cloudflare D1 (SQLite) |
| Photo storage | Cloudflare R2 |
| Deployment / host | Cloudflare Workers (@cloudflare/vite-plugin, apps/web/wrangler.jsonc) |
| Package manager | bun |
| Toolchain | biome (lint/format/check) |
Required TanStack libraries (brief)
Present: Start, Router, CLI, Intent (via npx), Query, Table, Form, Store, DB, AI. Still to add & demonstrate: TanStack Hotkeys, TanStack Pacer, TanStack Virtual.
External data sources
- Pitchfork scores via The Fork —
https://the-fork.vercel.app - Discogs — release metadata (artist, title, year, label, tracklist)
- Last.fm — listening data, to suggest records to buy / find US deals
Architecture decisions
- Cloudflare + TanStack end-to-end. Prefer Workers-native primitives (D1, R2, Workers AI, Email, Cron Triggers) over third-party services.
- Drizzle over Prisma specifically for D1 support (see scaffolding note).
- AI photo flow: take a photo on iPhone in the web app → Workers AI (vision) / AI Gateway extracts artist/title/year → enrich via Discogs + The Fork → store in D1, image in R2.
- Public API (
/api/*route handlers) exposes the collection over HTTP so it can be consumed fromssh charliegleason.comand charliegleason.com. Read endpoints are public; writes require Clerk auth. - Admin (
/admin) is the only authenticated area (ClerkSignedIn/SignedOut+ server-sideauth()checks in loaders/server fns). - Environments: localhost and production only — no staging.
Environment variables
Public vars are prefixed VITE_. Secrets are set via wrangler secret put <NAME> in
production and live in .env.local for dev. See .env.example.
| Var | Scope | Purpose |
|---|---|---|
VITE_CLERK_PUBLISHABLE_KEY |
build-time | Clerk (client + auth.ts, via import.meta.env) |
CLERK_SECRET_KEY |
secret | Clerk server-side auth() |
VITE_SENTRY_DSN |
build-time | Sentry DSN (client + withSentry, via import.meta.env) |
VITE_SENTRY_ORG/_PROJECT/SENTRY_AUTH_TOKEN |
build-time | Sentry source-map upload (Vite plugin) |
D1 binding DB |
binding | Database, dev + prod (apps/web/wrangler.jsonc) |
DISCOGS_TOKEN |
secret | Discogs API |
REPLICATE_API_KEY |
secret | Replicate — professional studio photo generation |
LASTFM_API_KEY / LASTFM_USER |
secret | daily digest suggestions |
CRON_SECRET |
secret | guards POST /api/cron/digest |
Workers AI binding AI |
binding | Workers AI / AI Gateway (apps/web/wrangler.jsonc) |
R2 binding PHOTOS |
binding | Vinyl photo storage (apps/web/wrangler.jsonc) |
Images binding IMAGES |
binding | Cover resize (apps/web/wrangler.jsonc) |
Email binding EMAIL |
binding | Daily digest send (apps/web/wrangler.jsonc) |
Deployment notes
- Deploys run in CI (
.github/workflows/deploy.yml, on push tomain), not locally.VITE_CLERK_PUBLISHABLE_KEY/VITE_SENTRY_DSNare build-time inlined, so a localbun run deploywould bake the test Clerk key into prod (→ "Development mode" banner). CI builds withpk_live_…+ Cloudflare creds from GitHub repo secrets;.env.localstays on test keys. Runtime Worker secrets are set once viawrangler secret putand persist across deploys (CI doesn't touch them). bun run buildthenwrangler deploy(seepackage.jsonscripts) — what CI runs.- Worker entry is
apps/web/src/server.ts(wranglermain), not the TanStack default — it wraps@tanstack/react-start/server-entry'sfetch, adds ascheduled(cron) handler for the daily digest, and wraps the whole handler in@sentry/cloudflarewithSentryfor runtime error capture. Keep all three when touching the entry. (The oldinstrument.server.mjswas removed — it never instrumented the worker.) - Bindings (D1
DB, R2PHOTOS, WorkersAI,IMAGES,EMAIL, Cron) are declared inapps/web/wrangler.jsonc; workernameisrecords. D1database_idis provisioned (records, WNAM region). - Custom domain
records.charliegleason.comis attached via a Workers route / custom domain in the Cloudflare dashboard orapps/web/wrangler.jsoncroutes. - Daily digest (
apps/web/src/lib/digest.ts): Last.fm top albums minus the collection → email via theEMAILbinding using Cloudflare Email Sending (env.EMAIL.send({ from, to, subject, html })— structured API, no MIME lib).send_emailbinding is{ name: "EMAIL", remote: true }(send to anyone). Needs the sender domain onboarded for Email Sending (cf-bounce subdomain + SPF/DKIM/DMARC TXT — apex MX untouched);FROMmust be on that domain (apex, not the worker subdomain). Crontriggers.crons(0 14 * * *) →scheduled; alsoPOST /api/cron/digest(CRON_SECRET). Sends nothing when there are no suggestions.
Known gotchas
- Dev uses remote bindings — there is NO local DB.
remoteBindings: trueinapps/web/vite.config.ts+remote: trueon each binding inapps/web/wrangler.jsoncmeans localhost reads/writes the real Cloudflare D1/R2. Apply migrations with--remoteonly (bunx wrangler d1 migrations apply records --remote). better-sqlite3is still independenciesbut unused after the D1 move — safe to remove later.- Auth boundary = write server fns, not the UI.
/admin's<SignedIn>gate is UX only; the real check isauthMiddleware(apps/web/src/lib/auth.ts, Clerk backend SDK) attached tocreateRecord/updateRecord/deleteRecord. Reads (listRecords,/api/*) are intentionally public. NeedsCLERK_SECRET_KEYin.env.local(dev, read by the Cloudflare Vite plugin) andwrangler secret put CLERK_SECRET_KEY(prod). - AI = Claude Sonnet 4.6 via Cloudflare Workers AI partner models + Unified Billing
(
apps/web/src/lib/ai.ts,runClaude→env.AI.run('anthropic/claude-sonnet-4.6', body, { gateway })). NoANTHROPIC_API_KEY— Cloudflare bills it (Workers Paid + credits).runClaudeis the entire AI surface: swap it back to the Anthropic SDK + BYOK in one file if needed. Body is the Anthropic Messages format (vision + tools pass through). Caveat: server-sideweb_searchis undocumented on the Unified- Billing path —identifyWithWebSearchis best-effort and fails closed to the Discogs pick-list / manual search. (TanStack AI isn't used — it doesn't expose Claude's server-side tools.) - Photo flow (
apps/web/src/lib/analyze.ts): vision read → Discogs lookup → web-search escalation when unsure → Pitchfork./api/photos/$serves R2 objects. - Three images per record.
capturePhotoKey= the iPhone shot (R2captures/, admin only — omitted from/api/records).coverImageKey= the Discogs- sourced cover, resized with the Cloudflare Images binding (env.IMAGES→ webp ≤600px) atcreateRecordtime (apps/web/src/lib/images.ts).professionalImageKey= a studio product shot generated from the capture via Replicate (see below). Which one displays is one shared helper —displayCoverKey(apps/web/src/lib/cover.ts): an approved professional photo wins, else the Discogs cover, else (admin only) the capture. Needs Image Transformations enabled on the account; fails closed to no cover. Public payloads go throughtoPublicRecord(same file), which drops the capture key + the internalprofessionalError/professionalPredictionId. - Professional photo (
apps/web/src/lib/professional.ts+apps/web/src/lib/replicate.ts): a newprofessionalqueue mode (alongsideanalyze/refresh) reads the capture from R2 and runs two Replicate passes — Flux Kontext (black-forest-labs/ flux-kontext-pro, identity-preserving relight/straighten/crop on a plain bg) then BiRefNet (men1scus/birefnet, background matting → transparent) — canonicalises to a webp-with-alpha underprofessional/, and lands the row atprofessionalStatus: 'ready'. It's best-effort and always acks (no auto-retry → no repeat Replicate charge); regeneration is a manual action. Review gate: areadyphoto isn't shown until an admin clicks Use as cover (approved) on the detail page; Stop using reverts toready(kept in R2, not shown). Model ids/prompt are constants at the top ofprofessional.ts— the exact Replicate input schemas need a real-key smoke test.REPLICATE_API_KEYis a runtime secret (wrangler secret put). - The Fork (
apps/web/src/lib/the-fork.ts) has no query API — it ships a 20 MB staticalbums.json(28k Pitchfork reviews:{artist,title,score,url,...}). We fetch it (edge + isolate cached), normalize, and match locally. Fails closed (null). - Discogs uses a personal access token (
DISCOGS_TOKEN,Authorization: Discogs token=…) with a mandatory unique User-Agent; 60 req/min. recordCreateSchemavsrecordInputSchema: create accepts enrichment fields (discogs/pitchfork/cover/source); the edit form uses the narrower input schema so it can't null those out on update.- TanStack DB live-query collections are client-only (no SSR) — disable SSR on
routes that preload collections (see
db#meta-frameworkskill). - Re-run
bunx wrangler typesafter editingapps/web/wrangler.jsonc(regeneratesapps/web/worker-configuration.d.ts).
Next steps
See PLAN.md for the full phased build plan. Immediate items:
- Apply the migration to D1:
bunx wrangler d1 migrations apply records --remote. - Phase 1 — CRUD:
create/update/deleteRecordserver fns + TanStack Form pages. - Phase 1.5 — server-side Clerk auth on
/adminand write server fns. - Phase 2 — AI photo capture → Workers AI extraction → R2 storage.
- Phase 3+ — Discogs / The Fork / Last.fm enrichment, public API expansion, daily email via Cron + Cloudflare Email.