Skip to content

Agent guide (CLAUDE.md)

Visual website builder. React editor SPA → Cloudflare Workers API (Hono) → Neon Postgres (Drizzle) + R2 + Durable Objects. pnpm monorepo. Anything referencing Supabase, PostgREST, Foam/OPFS, a Rust SSG or Neon Auth is historical. Code is the final authority (especially packages/schema/src/schema.ts); docs can lag it. Rationale and history behind the rules below: docs/dev/CLAUDE-REFERENCE.md.

Load the skill before the work, not after:

  • Editor UI — panels/fields, ui-spec, mobile (frontend/src/mobile, /m/*), tokens, contrast, UI Lab fixtures → matriz-ui-change
  • Seeing a UI change — UI Lab URLs, Dev MCP screenshot_ui/inspect_console, previews, overflow/mobile checklist → matriz-visual-verify
  • A PR (open, update, babysit, merge), spawning/worktree agents, CI red/cancelled/stuck → matriz-ship-pr
  • Editor token, chrome-layout or lucide-icon change → also figma-editor-sync
  • Anything else (importer, CMS controls, section nav, MCP, migrations, open items) → its Doc map row; “why is it like this?” → grep decisions-logs/INDEX.md.

Lessons flow: new learnings go to the owning skill’s Lessons log or plans/AGENT-LEARNINGS.md (mutable facts) — not here. This file gains a line only for an invariant backed by a test, guard or decision.

Stop at the first rung that answers:

  1. graphify (~1 s, file:line; TS, Python, drizzle SQL): graphify explain "X" · affected "X()" --depth 2 · path "A" "B"; broad/noisy: graphify query "<q>" --budget 800; product area: graphify-out/DOMAINS.md. Never read GRAPH_REPORT.md (use graphify god-nodes).
  2. Serena MCP (read-only LSP over frontend, workers, packages; edits go through Edit): find_referencing_symbols, get_diagnostics_for_file. It binds to the launch checkout (in a worktree guarda-serena.sh blocks it) and often disconnects: then graphify, then Grep.
  3. Grep / Glob, then Read with offset/limit (edits and tests still need the file). Never read a decisions-logs/archive/ chunk whole — grep INDEX.md.

Site/editor state: prefer Dev MCP read tools (summarize_page_dom over read_dom) to SQL. Import-graph rules: pnpm depcruise (frontend and workers share code only via packages/*; packages never import a consumer; part of ci:local). Ready to push: pnpm ci:local --changed.

  • On startup: Serena initial_instructions, ponytail on, and await the graph — Monitor until-loop (not sleep) on graphify-out/graph.json (log graphify-out/.session-build.log) before Grep/Read sweeps. Refresh in the background (scripts/agent/graph.sh, run_in_background; ~40 s). Cloud: .claude/hooks/session-start.sh does setup; locally run scripts/agent/graph.sh. Record: plans/HARNESS-MODERNIZATION.md §1.
  • ponytail (vendored in .claude/, “simplest thing that works”) never outranks a guard, test, decision or boundary in this file. /ponytail lite or PONYTAIL_DEFAULT_MODE=off turns it down.

Ponytail reaches subagents by itself (SubagentStart hook); graphify’s guard nudges once per session, so every spawn prompt carries the tooling rule. Ports, worktree pnpm install, do-not-touch PR lists: matriz-ship-pr §1.

  • Paste: “Locate with graphify explain|affected|path before Grep/Read; Read with offset/limit; run pnpm ci:local --changed (and any PGlite suite) in the background; report conclusions with file:line, never file dumps.”
  • Worktree agents have no graphify-out/: add --graph "$(git rev-parse --path-format=absolute --git-common-dir)/../graphify-out/graph.json".
  • Hand over what you know (paths, ids, PRs, decisions, known-red tests); ask for a bounded report.
  • Smallest agent: one known file → none; read-only sweep → Explore; independent tasks → parallel, one message. Never read another agent’s transcript.
  • Always pass model (else it inherits the orchestrator’s). haiku: lookups, sweeps, CI checks, exact-spec edits. sonnet: bounded implementation, test fixes, rule-clear conflicts, small reviews. opus: cross-cutting design, security/privacy, merges where a side loses behaviour — say why in one line. Unsure → one tier down, report blockers rather than guess.
  • Test narrowly first (one vitest file); the full gate once, before the push.
Doc Owns
STATUS.md Current state — the daily entry point.
decisions-logs/ (DECISIONS.md is a stub) Why. Frozen chunks in archive/ (find via INDEX.md); new ones one file each in entries/ (how-to: README.md; Conventions).
ARCHITECTURE.md Topology, auth flow, DB roles, env-var catalogue (§4), adding routes.
docs/architecture/OVERVIEW.md + TERMINOLOGY.md Durable architecture + canonical vocabulary.
plans/OWNER-OPS.md + plans/LOOSE-ENDS-EXECUTION-SPECS.md Owner escalation ledger + S-series execution specs.
plans/PENDING-WORK.md Canonical cross-cutting open items (D:pending-work-canonical); links out to the ledgers above, OPEN-DECISIONS-LEDGER.md and BONDLAYER-FOLLOWUPS-WORKSTREAM.md rather than duplicating them.
docs/DOCS-POLICY.md Doc classes, update/banner rules, archiving, the two docs sites.
db/MIGRATIONS.md Drizzle migration procedure.
docs/architecture/BONDLAYER-IMPORT-PIPELINE.md + BONDLAYER-EDITOR-JSON-CAPTURE.md Bondlayer capture + import.
docs/intent-system/MCP-CONNECT-RUNBOOK.md + MCP-OAUTH-SETUP.md Connecting external agents over MCP.
docs/architecture/COLLECTION-CONTROLS.md data-mf-* collection controls, and debugging them.
docs/architecture/SECTION-NAV.md The section_nav element: heading ids, level map, layouts, Dev MCP authoring.
docs/Research/BONDLAYER-*.md Bondlayer captures — research, not spec (map: plans/BONDLAYER-FOLLOWUPS-WORKSTREAM.md).
design/figma/FIGMA-SYNC.md Figma mirror of the editor UI (bZ17CtfEJ11rcV8UHNGKvh); token (frontend/src/index.css, tailwind.config.js), chrome-layout or lucide-icon changes sync BOTH ways via figma-editor-sync (code is source of truth; edit nodes by ID, never recreate).
plans/AGENT-LEARNINGS.md Living ledger of gaps, flows in place and traps agents hit (mutable facts; skills point here).

archive/ is frozen — never update it. The Python importer apps/bondlayer-worker/ is live, NOT archived: it runs on the matriz-import-runtime Cloudflare Container (apps/bondlayer-worker/edge/; IMPORT_RUNTIME="container" on skyfall-api); Render is a retired rollback path.

Reading Actions, reruns, merge-when-green and job timings: matriz-ship-pr.

  • pnpm ci:local IS the pre-push gate (workflow commands + db-guard + the Python importer tests); the hook runs it scoped to changed paths. Bypass one push: git push --no-verify / SKIP_CI_LOCAL=1.
  • GitHub Actions results are REAL — check them on PRs and master. Only Actions runs the Workers integration suite (workers/test/integration.test.ts; self-skips locally, so a local all-green does NOT prove it) and the deploy workflows.
  • Importer tests need pip3 install -r apps/bondlayer-worker/requirements.txt; a ModuleNotFoundError (openpyxl) is a local-environment gap, not a real failure.
  • Deploys: skyfall-api via Cloudflare Workers Builds, the editor via Pages Git integration, the importer via deploy-import-runtime.yml. deploy-api.yml/deploy-editor.yml are manual (workflow_dispatch) break-glass copies of those two.
  • Runner tiers (D:ci-runner-tiers; values and why, measured: plans/OWNER-OPS.md § 2026-09-27): since 2026-09-28 CI_CHECKS_RUNS_ON=vps (checks), CI_HEAVY_RUNS_ON/CI_DOCKER_RUNS_ON=oracle (test shards, frontend build, importer image); CI_RUNS_ON unset, so deploys and workers live run on ubuntu-latest, blocked by GitHub billing. Never set CI_RUNS_ON to a self-hosted label — it’s every tier’s fallback and the only setting secret-bearing jobs read. Runner setup lives in no-tag/vps-infra (bootstrap/runner-hooks/LEIA-ME.md, MAQUINAS.md, ORACLE-RUNNER.md) — don’t restate it here.
Terminal window
pnpm install # workspace root (Node >= 20, pnpm only)
./start-dev.sh # Worker API :8787 + Vite frontend :3001
pnpm typecheck # frontend tsc -b + workers tsc --noEmit
pnpm test # frontend + workers vitest (--run)
pnpm lint # eslint both
pnpm build # frontend production build
pnpm hooks:install # once per clone: the pre-push ci:local hook
pnpm ci:local # everything
pnpm ci:local --changed # suites whose paths changed vs origin/master; in frontend/workers only tests whose
# import graph reaches a changed file (+ source-reading "scanner" tests) — scripts/vitest-scoped.sh
pnpm ci:local --pr # verify a checked-out PR like CI: --changed vs fresh origin/master
# + the frontend build (+ importer image smoke if Docker runs)
pnpm --filter ./workers dev # wrangler dev (API only)
pnpm --filter ./workers test:node # PGlite/node-only worker tests (one migrated PGlite snapshot per run — see Gotchas)
pnpm --filter ./workers db:generate|db:migrate|db:check|db:drift # drizzle-kit
cd apps/bondlayer-worker/src && python3 test_element_mapper.py # Bondlayer importer (plain python3, no pnpm)
cd apps/bondlayer-worker/src && for f in test_*.py; do python3 "$f"; done # the whole importer suite

Worker secrets: wrangler secret put <NAME> --cwd workers — never plaintext vars (a var shadows a same-named secret). .env.example is stale; full detail in ARCHITECTURE.md §4 and workers/src/middleware/types.ts.

Name Purpose Absent ⇒
VITE_CLERK_PUBLISHABLE_KEY (frontend) Clerk key main.tsx throws
VITE_WORKERS_ORIGIN (frontend) http://localhost:8787 for local dev same-origin (Pages proxy)
NEON_DATABASE_URL, CLERK_SECRET_KEY, CLERK_WEBHOOK_SECRET service DB; Clerk verify; Svix required
NEON_AUTHENTICATED_DATABASE_URL RLS tenant path app-layer isolation only
CLERK_JWT_KEY networkless token verify — rotate with the Clerk signing key or every token 401s JWKS fetch
MEMBER_SESSION_SIGNING_KEY member session JWT redeem 503s, all anonymous
MAIL_CREDENTIALS_KEY sealed-box (BYO email, member Google OAuth); rotating invalidates all sealed rows; Google sign-in also needs MEMBER_SESSION_SIGNING_KEY those routes 503
CF_ANALYTICS_API_TOKEN + var CF_ACCOUNT_ID analytics query route 503s
TURNSTILE_SECRET_KEY (secret) + TURNSTILE_SITE_KEY (public var in wrangler.jsonc) per-form + member sign-in Turnstile (D:turnstile-published, D:members-turnstile-published); widget renders only when BOTH are set; its hostnames must cover every published host no widget, CSP unchanged; flagged forms skip verification (secret unset)
PLATFORM_OPERATOR_CLERK_IDS comma-separated Clerk user ids allowed on the shared-account control-plane routes (Cloudflare Pages/domains, Neon branches, MCP provisioning tools) nobody (those routes 403)
GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET GitHub connector App (D:github-connector-full-scope): full repo write scope, so the key writes to every installed repo — rotation in docs/architecture/GITHUB-CONNECTOR.md connector routes 503
GITHUB_STATE_SECRET root of install/authorize state signing (D:github-connector-pr-writer) falls back to GITHUB_WEBHOOK_SECRET
CLOCKWORK_ACCESS_CLIENT_ID/_SECRET + var CLOCKWORK_API_BASE Access service token for the GitHub connector’s clockwork alert card no card; the audit_events alert row alone
GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET App user-OAuth code exchange (/api/github, callback /settings/github/authorize/callback) authorize routes 503
OPS_ADMIN_TOKEN /api/ops cleanup door — mount nothing else on it 503
DEV_MCP_TOKEN internal Dev MCP /api/dev-mcp; separate secret; its browser tools can’t reach localhost 503
DEV_MCP_CLERK_USER_ID Clerk user screenshot_ui/inspect_console sign in as, to capture the editor (D:dev-mcp-signed-in-capture) captures signed out
CLOUDFLARE_API_TOKEN/_ACCOUNT_ID/_ZONE_ID custom-domain TLS (_ZONE_ID needs a fallback origin) — not the same-named Actions secrets TLS inert
IMPORT_RUNTIME (+ IMPORT_* / BONDLAYER_* origins) importer host (live "container") "render"
IMPORT_WORKER_TOKEN importer shared secret, same value on both hosts proxy 503s
AGENTE_MAQUINAS (var, names only) + per machine binding AGENTE_<M> and secret AGENTE_SEGREDO_<M> server connector read routes /api/projects/:id/servers (D:server-connector); the var maps machine → project, binding, secret name; never an address or value machines 404, list 503; missing binding/secret ⇒ 503 disabled, no outbound call
vars DEV_BYPASS_AUTH (local only), RLS_ENFORCED, PUBLISH_ROOT_DOMAIN; binding PUBLISH_ANALYTICS auth bypass; RLS; canonical publish host; page views serve /published/:siteId as-is; analytics no-op
binding IMAGES (Cloudflare Images) compresses uploaded image BYTES for MCP uploads (lib/image-pipeline.ts, D:mcp-image-pipeline) byte uploads of rasters refused (never stored raw); URL uploads still compress via cf.image
Layer Stack
Frontend React 19, Vite 7, TS strict, Zustand 5, shadcn/ui + Radix, Tailwind, Clerk (@clerk/clerk-react), TipTap
API Cloudflare Workers, Hono, Drizzle over Neon HTTP (workers/src/)
DB Neon — canonical schema packages/schema/src/schema.ts; workers/src/db/schema.ts is a re-export shim (import from it in Workers code, edit the package)
Storage / Realtime R2 (assets, published HTML, Yjs snapshots); Durable Object IntentRoomDO (D60/D61)
Shared packages @matriz/schema, @matriz/site-render (shared by preview AND publish — parity), @matriz/import (source-blind adapters)
Importer apps/bondlayer-worker/ — Python, Bondlayer editor.json → Site JSON v1; tests are bare python3 test_*.py, not pnpm
Deploy Pages skyfall → app.matriz.design; Worker skyfall-api → api.matriz.design

Routes: / dashboard · /editor/:siteId · /cms/:siteId[/:collectionId] · /import · /sign-in, /sign-up and /ui-lab (dev only) outside AuthGuard.

Auth + tenancy. Clerk is the identity provider; there is no authStore — use Clerk hooks. The Clerk org IS the project (users.id/projects.id are Clerk text ids, synced by /api/webhooks/clerk). projects (tenant boundary) → targets (deployable site); guards authMiddleware → projectAccessMiddleware. getScopedDB(c) is RLS-scoped only when RLS_ENFORCED + the authenticated URL are set — until then middleware is the isolation. getServiceDB is BYPASSRLS: only for provisioning/publish/webhooks, and annotate why.

Naming drift. A “site” in code (/editor/:siteId, site_pages, siteId) is a target; API scope keys are target_id/project_id. Don’t “fix” it wholesale — follow the file’s convention. Skyfall → Matriz is rebranded in code, but skyfall* CF/Neon resource names, the skyfall_config table and SKYFALL-SITE-JSON-V1.md are frozen — never rename them.

Property panels live in ui-spec/ (#765): a field editing the selected element goes in ui-spec/specs/*.panel.json + its parity test, never a hand-written React section (carve-outs: plans/EDITOR-UI-SPEC.md §9.5; detail: matriz-ui-change).

UI Lab (/ui-lab) renders the real editor chrome on adversarial fixtures without auth or DB — check overflow there. Keep fixtures adversarial; every new panel/API call gets a route in frontend/src/pages/ui-lab/mock-api.ts (an unmatched GET returns [] and silently wipes seeded state). Running it: matriz-visual-verify.

Save path (sole write path): Edit → Zustand → save-v2 sink (frontend/src/lib/save-v2/) → debounced POST /api/nodes/batch?target_id=…. The scope param is mandatory (omitting it silently loses edits — D50). Never add a parallel write path; go through PropertyCommands → the sink (text slots via PropertyCommands.updateSlotValues).

Publish (@matriz/site-render → R2 → /published/:siteId):

  • script-src is 'none' | sha256 hashes | (ONLY a page rendering a spam.turnstile form) https://challenges.cloudflare.com — CI-gated (published-csp-invariant.test.ts, D:turnstile-published).
  • Every third-party origin is in packages/site-render/src/third-party-ledger.ts — its diff is the review surface.
  • No runtime JS/CSS deps; rendering changes go in packages/site-render, never one consumer.
  • embed (normalizeEmbedUrl) is the sole iframe producer; a raw iframe coerces to div.
  • custom_code is an opaque-origin srcdoc iframe, sandbox="allow-scripts allow-forms"; allow-same-origin is unrepresentable and CI-gated (sandbox-gate.test.ts) — on the canvas too.

Stores: always subscribe via selectors — useEditorStore(s => s.selectedElementId).

  • TypeScript strict, verbatimModuleSyntax → use import type; path alias @/ → frontend/src/
  • pnpm workspace filters, never npm/npx; scripts run from repo root
  • UI: shadcn/ui first → Radix primitives → Tailwind
  • Commits: conventional — feat(scope):, fix(scope):
  • Decisions, one file each, no numbers: copy decisions-logs/entries/_TEMPLATE.md to decisions-logs/entries/<YYYY-MM-DD>-<slug>.md (heading # D:<slug>), cite it as D:<slug> — never a number. Numbering is retired (D:decisions-logs); old decisions keep their numbers and are cited bare (D50), nothing new claims one. One file per decision ⇒ parallel PRs never conflict or renumber. decision-number-guard.test.ts fails the build on a slug collision, a filename/heading mismatch, or any new numbered ## D<n> heading — never work around it.
  • Schema changes: edit packages/schema/src/schema.ts → pnpm --filter ./workers db:generate → migrate; db-guard.yml fails CI on drift
  • Node styles are kebab-case CSS property keys (camelCase is silently invalid — D56): base + state layers + max<px>/min<px> media layers; custom states render as .state-<name>
  • Design-token CSS var names (--color-primary, --radius-md, …) are a contract defined in packages/site-render/src/token-presets.ts (D54/D55)

Rationale for each: CLAUDE-REFERENCE §10.

  • frontend/src/lib/postgrest.ts is a legacy-named barrel over lib/api/; there is no PostgREST.
  • Canvas ↔ iframe postMessage: validate event.origin.
  • workers/src/worker.ts is the deploy entry; tests import index.ts — don’t move agents imports into index.ts.
  • Uploads go through /api/assets/sign; cdn_url is derived server-side (D38) — never construct it client-side. assets.tier is server-side too (D:one-cdn-url): a design asset is public from creation at its one URL /api/asset-pub/<key> (editor and live site alike); member (member uploads / form attachments, the ${projectId}/members/ namespace) stays private unless a published page references it.
  • MCP write_component is slated for deprecation (D51) — build nothing new on it.
  • The canvas renders embed/custom_code from @matriz/site-render markup — never fork normalizeEmbedUrl/escapeSrcdoc.
  • db/*.sql snapshots and older docs can be stale; verify against packages/schema and routes.
  • Hand-applied migrations MUST schema-qualify drizzle."__drizzle_migrations" (db/MIGRATIONS.md → “Two ledger tables”).
  • data-member-visible is an AND-combined token list and an unknown token HIDES (D:member-auth-blocks); its grammar is in MEMBER_RUNTIME_SOURCE AND hand-mirrored in frontend/public/preview.html (applyMemberPreview) — change both, keep members-library.verify.test.ts green. Imported _state gates become these tokens; _state is never evaluated as a CMS field.
  • PGlite suites boot from one migrated snapshot (D:pglite-snapshot): changing how pg-test-db.ts applies migrations means bumping SNAPSHOT_FORMAT (workers/test/node/helpers/pglite-snapshot.ts); never keep a test DB across tests.
  • Importer tests auto-collect — never hand-list them; sys.exit(run(globals())) must stay at the end of each file.
  • custom_head_css/custom_body_js are LIVE at publish, emitted AFTER Matriz’s floor rules on publish and canvas alike, so they win specificity ties (D:site-css-after-floors, site-css-order-parity.test.ts); migrating rules out of them MUST remove them, or they double-apply.
  • Text lives in slot_values.text, not content (D:text-slot-canonical) — write the slot (PropertyCommands.updateSlotValues/updateContent, Dev MCP update_text_node), never raw SQL on dom_nodes.content.
  • data-bl-name is not unique — assume fan-out; a rule on an unrenamed default (Text) is almost always a mistake.
  • Never commit .env* files or keys; Worker secrets only via wrangler secret
  • Never force-push master
  • Drizzle query builder only — no string-concatenated SQL
  • Don’t touch archive/, node_modules/, dist/, generated workers/drizzle/ artifacts by hand

This repo reports status to a shared Neon DB read by a personal assistant bot (Gralha). Neon project spring-fog-44567478 (org Étude-Design, “agent-reports”) — Neon MCP run_sql against THIS projectId, never this repo’s own Neon project.

  • End of a meaningful work session (only if something actually happened) — a complete report, not just a headline: INSERT INTO agent_events (project, event_type, summary, detail) VALUES ('SKYFALL', 'session_checkin', '<one-line summary>', '<full report>');
  • On a PR merge: INSERT INTO agent_events (project, event_type, summary, url) VALUES ('SKYFALL', 'pr_merged', '<PR title>', '<PR URL>');
  • When current status changes (not every session — only when it’s different): INSERT INTO agent_work_state (project, status, blocked_on, next_up) VALUES ('SKYFALL', '<status now>', <blocker or NULL>, <next step or NULL>) ON CONFLICT (project) DO UPDATE SET status = EXCLUDED.status, blocked_on = EXCLUDED.blocked_on, next_up = EXCLUDED.next_up, updated_at = now();
  • When you discover a real date (compliance deadline, renewal, launch target): INSERT INTO agent_deadlines (project, label, due_date, note) VALUES ('SKYFALL', '<what it is>', '<YYYY-MM-DD>', '<context>');

Keep summaries short and specific. Never write a check-in for a session where nothing happened — this feeds a bot built on scarcity of voice.

  • Multi-agent coordination is moving into clockwork (no-tag/clockwork) cards; follow its docs, not a copy here.
  • Cloud sessions without the tailnet keep using GitHub issue #1063 until the cards API exists: comments only, first line the agent handle in backticks, then CLAIM / DONE / DROP.
  • No secrets in any channel.
  • Fewer, bigger PRs (owner rule, 2026-10-16): one PR per feature; follow-ups and fixes for your own open PR go to its branch, never a new PR. Docs-only commits get [skip ci], except where docs are checked by tests; here Workers Builds deploys regardless. Never merge a PR whose checks are pending, failed or skipped.
  • vps-infra#71 adds the canonical section to every repo; this one is short so it can be swapped for theirs.

Pointers only; the skills’ rules live in .claude/skills/ (claim-ledger, claim-audit, first-hand-research, licence-check, paper-review) and vps-infra/toolkit/LEIA-ME.md, “Research skills”.

  • Marks (ledger class): [V]=verified [INFER]=inference [?]=unverified. The same marks go in docs and in claim ledgers; a mark moves up a class only by opening the source.
  • Archive: research briefs, raw reports (banner “raw, unverified, not citable”), ledgers and audits go in docs/research/, ledger and audit beside the report they came from. Scratch space is cleaned; commit the archive at once.
  • Source hierarchy: the publisher’s own page, spec or file; then the institution’s published terms; then the official gazette’s legal text; then press, blogs, forums; a model-written summary is a lead, never a source.
  • Traffic: one designated agent per public API or host, requests at least 3 s apart, a descriptive User-Agent, stop at the first refusal; other agents read HTML and PDF pages only. No logins, CAPTCHAs, account creation or downloads of executables.