Agent guide (CLAUDE.md)
Visual website builder. React editor SPA → Cloudflare Workers API (Hono) → Neon Postgres (Drizzle) + R2 + Durable Objects. pnpm monorepo. The old Supabase/Rust/Fly.io stack was removed 2026-06-26 (tag archive/pre-cleanup-20260626) — anything referencing Supabase, PostgREST, Foam/OPFS, or a Rust SSG is historical.
Doc map (read in this order)
Section titled “Doc map (read in this order)”| Doc | Owns |
|---|---|
STATUS.md |
Current state — what works, what’s open. The daily entry point. |
DECISIONS.md |
Why — append-only architectural decisions (D1…). |
ARCHITECTURE.md |
Runtime topology, auth flow, DB roles, how to add routes. |
docs/architecture/OVERVIEW.md + TERMINOLOGY.md |
Durable system architecture + canonical vocabulary. |
plans/OWNER-OPS.md + plans/LOOSE-ENDS-EXECUTION-SPECS.md |
Owner escalation ledger + S-series execution specs (the old pivot-plan/INDEX roadmap is archived). |
docs/DOCS-POLICY.md |
How docs are updated, banner rules, archive procedure, the two Starlight docs sites (apps/docs-product → docs.matriz.design, apps/docs-dev → dev.matriz.design). |
db/MIGRATIONS.md |
Drizzle migration procedure. |
docs/architecture/BONDLAYER-IMPORT-PIPELINE.md + BONDLAYER-EDITOR-JSON-CAPTURE.md |
Bondlayer import: how a site is captured and transformed. |
docs/Research/BONDLAYER-*.md |
Competitive capture of Bondlayer’s authoring surfaces (research, not spec) — see the corpus map in plans/BONDLAYER-FOLLOWUPS-WORKSTREAM.md. |
archive/ is a frozen pre-pivot snapshot — never update it. Docs can lag the code (the Clerk pivot landed 2026-07-03; docs written before that may still describe Neon Auth and sites-as-tenant). Code is the final authority, especially packages/schema/src/schema.ts.
⚠️ The Python Bondlayer importer is NOT archived. It lives at apps/bondlayer-worker/ and is
actively deployed. Several docs written before 2026-07-26 pointed at
archive/2026-07-13-bondlayer-tooling/bondlayer-import/ — that path has never existed; only the
frozen JS xlsx converter sits under archive/2026-07-13-bondlayer-tooling/.
CI: GitHub Actions is LIVE again (2026-08-02) — pnpm ci:local remains the pre-push gate
Section titled “CI: GitHub Actions is LIVE again (2026-08-02) — pnpm ci:local remains the pre-push gate”Actions was dead 2026-07-24 → 2026-08-02 (payment failure); it now runs and its
results are REAL — check them on PRs and master. Two things only Actions covers:
the Workers integration suite (workers/test/integration.test.ts gates on a
real NEON_DATABASE_URL secret and self-skips locally — a local all-green does
NOT prove it), and the deploy workflows (deploy-api.yml/deploy-editor.yml,
which now run AND succeed — note they overlap the Cloudflare Git-integration
deploys; see OWNER-OPS about picking one). pnpm ci:local is still the
pre-push gate for everything it covers.
pnpm ci:local IS the gate. It runs the same commands those workflows
declare, plus two things they never covered (db-guard, and the 400+ Python
importer tests). A pre-push hook runs it automatically, scoped to the paths
you changed — install once per clone:
pnpm hooks:installpnpm ci:local # everythingpnpm ci:local --changed # only suites whose paths changed vs origin/masterBypass a single push with git push --no-verify (or SKIP_CI_LOCAL=1 git push).
test_importer_refinements.py needs pytest — pip3 install pytest — otherwise
that one file is skipped with a warning.
Deploys do not go through Actions either. The Worker (skyfall-api) deploys
via Cloudflare Workers Builds (native Git integration, root directory
workers, NODE_VERSION=22 build variable — wrangler 4 needs Node ≥ 22 and the
repo’s .nvmrc pins 20 for the test matrix). The editor deploys via the Pages
project’s own Git integration. The Render materializer
(skyfall-keru.onrender.com) deploys from its own pipeline and must be
redeployed separately when apps/bondlayer-worker/ changes.
Commands
Section titled “Commands”pnpm install # workspace root (Node >= 20, pnpm only)./start-dev.sh # Worker API :8787 + Vite frontend :3001
pnpm typecheck # frontend tsc -b + workers tsc --noEmitpnpm test # frontend + workers vitest (--run)pnpm lint # eslint bothpnpm build # frontend production build
pnpm --filter ./workers dev # wrangler dev (API only)pnpm --filter ./workers test:node # PGlite/node-only worker testspnpm --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 suiteEnvironment
Section titled “Environment”Frontend (frontend/.env.local):
VITE_CLERK_PUBLISHABLE_KEY— required (main.tsx throws without it)VITE_WORKERS_ORIGIN— optional; empty = same-origin via the Pages proxy, sethttp://localhost:8787for local dev against the local Worker
Worker (wrangler secret put <NAME> --cwd workers — never plaintext vars, a var shadows a same-named secret):
NEON_DATABASE_URL(owner/service role),CLERK_SECRET_KEY,CLERK_WEBHOOK_SECRET(Svix),NEON_AUTHENTICATED_DATABASE_URL(RLS tenant path)MEMBER_SESSION_SIGNING_KEY— 256-bit HS256 key signing the first-party member session JWT (__matriz_membercookie, Visitor Auth D73/VA-3). Secret-gated: absent ⇒ session mint/verify disabled (redeem 503s, edge gate treats every request as anonymous). Set on prodskyfall-api.CF_ANALYTICS_API_TOKEN— Account Analytics:Read token for the analytics query route (GET /api/targets/:id/analytics); pairs with theCF_ACCOUNT_IDvar; either absent ⇒ route 503s. Serving/writes unaffected.varsinworkers/wrangler.jsonc:DEV_BYPASS_AUTH(local only),RLS_ENFORCED,PUBLISH_ROOT_DOMAIN(unset/""⇒ legacy/published/:siteIdpath serves byte-for-byte; set ⇒ path route 301s to the target’s canonical host),CF_ACCOUNT_ID. Optional bindingPUBLISH_ANALYTICS(Analytics Engine — cookieless page-view writes; absent ⇒ no-op).
.env.example predates the Clerk pivot — trust this list and workers/src/middleware/types.ts over it.
Architecture
Section titled “Architecture”| Layer | Stack |
|---|---|
| Frontend | React 19, Vite 7, TypeScript strict, Zustand 5, shadcn/ui + Radix, Tailwind, Clerk (@clerk/clerk-react), TipTap |
| API | Cloudflare Workers, Hono, Drizzle ORM over Neon HTTP driver (workers/src/) |
| DB | Neon Postgres — canonical schema in packages/schema/src/schema.ts (39 tables); workers/src/db/schema.ts is a re-export shim (keep importing from it in Workers code, but edit the package) |
| Storage | R2 — assets, published HTML, Yjs snapshots |
| Realtime | Durable Objects — SiteDurableObject (Yjs Y.Text island, currently dormant), McpControlAgent, McpDataAgent |
| Shared packages | @matriz/schema, @matriz/site-render (renderer shared by editor preview AND publish — preview↔published parity), @matriz/import (source-blind import adapters) |
| Bondlayer importer | apps/bondlayer-worker/ — a Python service (Dockerfile → Render) that turns a captured Bondlayer editor.json into Matriz Site JSON v1. Separate from @matriz/import (which is the source-blind wizard side). src/parser.py → element_mapper.py/style_transformer.py/cms_mapper.py/slot_mapper.py → api_writer.py. Tests are bare python3 test_*.py in that dir, not pnpm. |
| Deploy | Editor: Cloudflare Pages project skyfall (frozen legacy id), live at https://app.matriz.design (custom domain; underlying Pages host skyfall-b5u.pages.dev, prod branch master). API: Worker skyfall-api (frozen legacy id) at https://api.matriz.design (custom domain; legacy host skyfall-api.simaomartinez.workers.dev — the workers.dev subdomain is simaomartinez, not b5u, CF rejected the rename). The deployed Cloudflare resource names stay skyfall* — CF can’t rename Workers/Pages/R2 in place, so they’re frozen legacy identifiers (see the rebrand decision). |
CI (.github/workflows/) |
⚠️ NOT RUNNING — see “CI is local” below. Checks: frontend-ci.yml, workers-ci.yml, packages-ci.yml, db-guard.yml (schema-drift gate). Deploys on push to master (path-filtered, + manual dispatch): deploy-api.yml (Worker), deploy-editor.yml (Pages) — both need the CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID repo secrets. |
Auth + tenancy (Clerk B2B2C + Split-Glass, live since 2026-07-03)
Section titled “Auth + tenancy (Clerk B2B2C + Split-Glass, live since 2026-07-03)”- Clerk is the identity provider. Frontend:
ClerkProviderinmain.tsx,/sign-in+/sign-uproutes,AuthGuard, session token attached asBearerbylib/api/client.ts. There is no authStore — use Clerk hooks. - The Clerk org IS the project.
users.id/projects.idare Clerk text ids (user_…/org_…)./api/webhooks/clerk(Svix-verified) syncs users/orgs/memberships into local projections. - Split-Glass hierarchy:
projects(tenant boundary) →targets(deployable site, formerlysites). Worker guards:authMiddleware(@clerk/backendverifyToken; setsuserId,orgId) →projectAccessMiddleware(org-claim fast path, elseproject_usersjoin; setsprojectId/targetId). - RLS: Clerk-claims policies in
db/migrations/20260703_clerk_rls_baseline.sql;getScopedDB(c)routes through the tenant role only whenRLS_ENFORCED+ the authenticated URL secret are set — until then app-layer middleware is the isolation.getServiceDBis BYPASSRLS: use only for provisioning/publish/webhooks and annotate why.
Naming drift (important)
Section titled “Naming drift (important)”“Site” survives everywhere in code — routes (/editor/:siteId), site_pages, siteId variables, frontend/src/lib/api/sites.ts — but the API scope keys are now target_id/project_id and sites.ts points at /api/targets. A “site” in frontend code is a target. Don’t “fix” this wholesale; follow the convention of the file you’re in.
Similarly, the product was renamed Skyfall → Matriz (rebrand decision). Source, docs, packages (@matriz/*), and wire markers (data-matriz-*, matriz-form-*, X-Matriz-Signature, sk_matriz_) are all rebranded, but the deployed Cloudflare/Neon resource names remain skyfall* as frozen legacy identifiers (Worker skyfall-api, Pages project skyfall, R2 buckets skyfall-assets/skyfall-yjs-snapshots, Neon project Skyfall-V2) — CF/Neon can’t rename these in place. The DB table skyfall_config and the docs/architecture/SKYFALL-SITE-JSON-V1.md spec filename are likewise frozen. Custom domains app.matriz.design (editor) / api.matriz.design (API) front the frozen hosts.
Frontend routes
Section titled “Frontend routes”/ Dashboard /import Universal import wizard/editor/:siteId Visual editor /sign-in, /sign-up Clerk (outside AuthGuard)/cms/:siteId[/:collectionId] CMS /ui-lab UI Lab (dev only, outside AuthGuard)UI Lab — visual inspection without auth or DB (/ui-lab)
Section titled “UI Lab — visual inspection without auth or DB (/ui-lab)”Use this to see editor UI changes and catch layout/overflow errors — it renders the REAL
editor chrome (TopBar, AppSidebar all modes, DOM tree, token editor, PropertiesPanel +
PropertyEditorShell) on deterministic fixtures. frontend/src/pages/ui-lab/: stores seeded
synchronously, /api/* served by a path-scoped window.fetch mock (token CRUD is interactive,
nothing persists), canvas iframe replaced by a placeholder, per-panel error boundaries so one
broken panel can’t hide the rest. Dev builds only.
Agent workflow: the app needs VITE_CLERK_PUBLISHABLE_KEY to boot but the lab never uses a
session — a fake key is fine: echo "VITE_CLERK_PUBLISHABLE_KEY=pk_test_$(echo -n 'x.clerk.accounts.dev$' | base64 -w0)" > frontend/.env.local
(gitignored). Then pnpm --filter ./frontend dev and screenshot http://localhost:3001/ui-lab
with Playwright/Chromium; ignore console errors from the clerk-js CDN load. Fixtures are
deliberately adversarial (long oklch()/clamp() values, long token names) — keep them that way;
they exist to expose truncation and overflow bugs. When you add a panel or API call to the
editor, give the lab’s mock (mock-api.ts) a matching route (unmatched GETs return [], which
can silently overwrite seeded store state on mount refetches — that’s the failure mode to check).
Save path (critical, sole write path)
Section titled “Save path (critical, sole write path)”Edit → Zustand (optimistic) → save-v2 sink (frontend/src/lib/save-v2/, installed in main.tsx) → debounced POST /api/nodes/batch?target_id=… (upsert-by-id + cascade-delete, slot_values per-key merge)- The tenant scope param on the batch flush is mandatory — omitting it 400s at the access middleware and silently loses edits (the D50 bug).
- Pending + in-flight edits mirror to an IndexedDB crash journal, replayed on next editor boot (
recoverJournaledSavesin App.tsx). - The legacy
OperationQueuesave path is gone. Never add a parallel write path; go through PropertyCommands → the sink. - Text slots: check
routeTextWrite(Y.Text CRDT guard) before anyslot_valuesREST write — text must have exactly one authoritative path. The Y.Text island is built but dormant (no active provider), so REST fallback currently runs.
Publish
Section titled “Publish”POST /api/publish/:siteId → DO flush → @matriz/site-render compiles dom_nodes to static HTML (inlined DTCG token CSS, responsive/state rules) → R2 → served publicly at /published/:siteId (with PUBLISH_ROOT_DOMAIN set, that path route 301s to the target’s canonical host instead). Published pages ship a strict CSP — script-src 'none' unless the page uses the sha256-hash-pinned interactions runtime; this invariant is CI-gated (published-csp-invariant.test.ts — script-src is forever 'none'|hashes). Every third-party origin a published page can touch is enumerated in packages/site-render/src/third-party-ledger.ts (inverse-guarded tests) — its diff is the review surface when adding origins. No runtime JS/CSS dependencies; rendering changes go in packages/site-render, never in just one consumer.
- Embeds:
element_type 'embed'— 8-provider allowlist-anchored registry (embed-registry.tsin@matriz/site-render; usenormalizeEmbedUrl). It is the sole iframe producer; a rawiframeelement still coerces todiv. Publish accumulates per-pageframe-srcorigins into R2customMetadata.cspFrameSrc(besidecspScriptHash); serve emits them into the CSP. - Custom code:
element_type 'custom_code'publishes as an opaque-origin sandboxedsrcdociframe —sandbox="allow-scripts allow-forms"hardcoded;allow-same-originis unrepresentable in the emitter and CI-gated (sandbox-gate.test.ts). - Edge analytics:
servePublishedAssetrecords cookieless page views to the optionalPUBLISH_ANALYTICSAnalytics Engine binding (never affects serving);GET /api/targets/:id/analyticsqueries it (CF_ACCOUNT_ID+CF_ANALYTICS_API_TOKEN); dashboard surface isTrafficCard(ProjectCard menu → Dialog).
Stores
Section titled “Stores”editorStore (DOM trees, selection, viewport), pagesStore, cmsStore, themeStore, presenceStore. Always subscribe via selectors: useEditorStore(s => s.selectedElementId).
Conventions
Section titled “Conventions”- TypeScript strict,
verbatimModuleSyntax→ useimport 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 get aD<n>entry inDECISIONS.md - D-number claiming (anti-collision — this raced 3× in one branch, D68→D69→D72): parallel sessions
all append to
DECISIONS.md, so a number picked at authoring time WILL be taken by the time you merge. Rules: (1) claim late — assign the realD<n>only in the final pre-merge sync: fetchorigin/master, take its maxD<n>+1, renumber, merge immediately (don’t let hours pass); (2) cite by slug until merged — while your decision is unmerged, reference it in code comments, specs, and tests as a stable slug (e.g.D:forms-email), NOT a guessed number, so the final renumber touches ONE file instead of eighty call sites; (3) numbers of MERGED decisions are permanent — cite those bare (D50,D56) as always; (4) on the merge conflict itself, resolveDECISIONS.mdas theirs + re-append your renumbered entries (append-only means your entries always go last); commit messages keep whatever number was current when written (immutable history — the ledger is the source of truth). - Schema changes: edit
packages/schema/src/schema.ts→pnpm --filter ./workers db:generate→ migrate;db-guard.ymlfails CI on drift - Node styles are kebab-case CSS property keys (camelCase keys are silently invalid in both preview and publish — D56); structure is
base+ state layers +max<px>/min<px>media layers, custom variant states render as.state-<name> - Design-token CSS var names (
--color-primary,--radius-md, …) are a contract:packages/site-render/src/token-presets.tsdefines it, the library tokenizer programs against it (D54/D55)
Gotchas
Section titled “Gotchas”frontend/src/lib/postgrest.tsis a legacy-named barrel overlib/api/— there is no PostgREST anymore.- Canvas ↔ iframe talk via
postMessage(elementClicked,inlineContentChanged,setSiteCss, …); selection debounced 50 ms; validateevent.origin. workers/src/worker.tsvsindex.ts: worker.ts adds theagents-based MCP DOs and is the deploy entry; tests import index.ts (miniflare lackscloudflare:email). Don’t moveagentsimports into index.ts.- Uploads: R2 via
/api/assets/sign;cdn_urlis derived server-side (D38) — never construct it client-side. - MCP
write_componentis slated for deprecation (Intent-Driven Rendering, D51) — don’t build new features on it. - The live editor canvas renders
embed/custom_codenodes live: embeds mount their real iframe on the canvas;custom_coderenders click-to-run in edit mode (a Run button mounts the sandboxed iframe on demand) and live in interactive/preview mode. The iframe markup is produced in React by@matriz/site-render(renderEmbeddedNodeHtmlinfrontend/src/lib/canvas-embed-render.ts→serializeNodeTree, attached as_matrizRenderedHtmlineditorStore.updateAllPreviews) and mounted by preview.html — there is no ES5 fork ofnormalizeEmbedUrl/escapeSrcdoc(the G3 parity gate still holds).custom_codestays opaque-origin sandboxed (allow-scripts allow-forms, neverallow-same-origin) even on the canvas. In edit mode the child iframe ispointer-events: noneso canvas selection still works. db/*.sqlsnapshots and older docs can be stale; verify againstpackages/schemaand the route files.- Importer tests auto-collect — never hand-list them. The 50
apps/bondlayer-worker/src/test_*.pyfiles are barepython3, and each ends withsys.exit(run(globals()))(_testkit.py), which collects every module-leveltest_*in definition order. That call must stay at end of file: a test defined below it does not exist when it runs. This replaced hand-maintained call lists that left 9 tests dead while the files still printed green — the printed count is of assertions (check()), not tests, so a dead test never showed. Collecting zero tests is now a hard failure; the two files whose assertions live inmain()/at import time keep their own entry points. custom_head_css/custom_body_jsare LIVE, not inert passthrough. Bondlayer’s project-levelwebHeadStyles/webBodyScriptsland in target settings and are emitted at publish as<style data-matriz="custom">(workers/src/lib/page-render.ts:378, and the standalone renderer inpackages/site-render/tools/render-site-json.ts:138). So imported custom CSS works — it is simply invisible to the property editor and to the render engine’s own cascade reasoning (see the note inpackages/site-render/src/text-baseline.ts:107). Anything that migrates rules out of that blob MUST remove them from it; leaving them in double-applies.data-bl-nameis an id by intent, a class by mechanism. Bondlayer designers rename elements to target them from custom code, and MATRIZ mirrors the field as the element name. But across the captures 31 of 46 referenced names match multiple nodes on purpose (bgblur33,square25) — fanning out is the author’s intent, not a bug. The exception is unrenamed defaults:Textmatches 1538 nodes, so a rule reaching a default name is almost always a mistake. Never assume a name is unique.
Boundaries
Section titled “Boundaries”- Never commit
.env*files or keys; Worker secrets only viawrangler secret - Never force-push
master - Drizzle query builder only — no string-concatenated SQL
- Don’t touch
archive/,node_modules/,dist/, generatedworkers/drizzle/artifacts by hand
Reporting to the cross-project tracker
Section titled “Reporting to the cross-project tracker”This repo reports status to a shared Neon DB a personal assistant bot (Gralha) reads. Neon
project: spring-fog-44567478 (org Étude-Design, “agent-reports”) — use the Neon MCP run_sql
tool 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.