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. 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 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:

Terminal window
pnpm hooks:install
Terminal window
pnpm ci:local # everything
pnpm ci:local --changed # only suites whose paths changed vs origin/master

Bypass 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.

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 --filter ./workers dev # wrangler dev (API only)
pnpm --filter ./workers test:node # PGlite/node-only worker tests
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

Frontend (frontend/.env.local):

  • VITE_CLERK_PUBLISHABLE_KEYrequired (main.tsx throws without it)
  • VITE_WORKERS_ORIGIN — optional; empty = same-origin via the Pages proxy, set http://localhost:8787 for 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_member cookie, Visitor Auth D73/VA-3). Secret-gated: absent ⇒ session mint/verify disabled (redeem 503s, edge gate treats every request as anonymous). Set on prod skyfall-api.
  • CF_ANALYTICS_API_TOKEN — Account Analytics:Read token for the analytics query route (GET /api/targets/:id/analytics); pairs with the CF_ACCOUNT_ID var; either absent ⇒ route 503s. Serving/writes unaffected.
  • vars in workers/wrangler.jsonc: DEV_BYPASS_AUTH (local only), RLS_ENFORCED, PUBLISH_ROOT_DOMAIN (unset/"" ⇒ legacy /published/:siteId path serves byte-for-byte; set ⇒ path route 301s to the target’s canonical host), CF_ACCOUNT_ID. Optional binding PUBLISH_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.

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.pyelement_mapper.py/style_transformer.py/cms_mapper.py/slot_mapper.pyapi_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: ClerkProvider in main.tsx, /sign-in + /sign-up routes, AuthGuard, session token attached as Bearer by lib/api/client.ts. There is no authStore — use Clerk hooks.
  • The Clerk org IS the project. users.id/projects.id are 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, formerly sites). Worker guards: authMiddleware (@clerk/backend verifyToken; sets userId, orgId) → projectAccessMiddleware (org-claim fast path, else project_users join; sets projectId/targetId).
  • RLS: Clerk-claims policies in db/migrations/20260703_clerk_rls_baseline.sql; getScopedDB(c) routes through the tenant role only when RLS_ENFORCED + the authenticated URL secret are set — until then app-layer middleware is the isolation. getServiceDB is BYPASSRLS: use only for provisioning/publish/webhooks and annotate why.

“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.

/ 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).

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 (recoverJournaledSaves in App.tsx).
  • The legacy OperationQueue save path is gone. Never add a parallel write path; go through PropertyCommands → the sink.
  • Text slots: check routeTextWrite (Y.Text CRDT guard) before any slot_values REST write — text must have exactly one authoritative path. The Y.Text island is built but dormant (no active provider), so REST fallback currently runs.

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 CSPscript-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.ts in @matriz/site-render; use normalizeEmbedUrl). It is the sole iframe producer; a raw iframe element still coerces to div. Publish accumulates per-page frame-src origins into R2 customMetadata.cspFrameSrc (beside cspScriptHash); serve emits them into the CSP.
  • Custom code: element_type 'custom_code' publishes as an opaque-origin sandboxed srcdoc iframe — sandbox="allow-scripts allow-forms" hardcoded; allow-same-origin is unrepresentable in the emitter and CI-gated (sandbox-gate.test.ts).
  • Edge analytics: servePublishedAsset records cookieless page views to the optional PUBLISH_ANALYTICS Analytics Engine binding (never affects serving); GET /api/targets/:id/analytics queries it (CF_ACCOUNT_ID + CF_ANALYTICS_API_TOKEN); dashboard surface is TrafficCard (ProjectCard menu → Dialog).

editorStore (DOM trees, selection, viewport), pagesStore, cmsStore, themeStore, presenceStore. 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 get a D<n> entry in DECISIONS.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 real D<n> only in the final pre-merge sync: fetch origin/master, take its max D<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, resolve DECISIONS.md as 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.tspnpm --filter ./workers db:generate → migrate; db-guard.yml fails 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.ts defines it, the library tokenizer programs against it (D54/D55)
  • frontend/src/lib/postgrest.ts is a legacy-named barrel over lib/api/ — there is no PostgREST anymore.
  • Canvas ↔ iframe talk via postMessage (elementClicked, inlineContentChanged, setSiteCss, …); selection debounced 50 ms; validate event.origin.
  • workers/src/worker.ts vs index.ts: worker.ts adds the agents-based MCP DOs and is the deploy entry; tests import index.ts (miniflare lacks cloudflare:email). Don’t move agents imports into index.ts.
  • Uploads: R2 via /api/assets/sign; cdn_url is derived server-side (D38) — never construct it client-side.
  • MCP write_component is slated for deprecation (Intent-Driven Rendering, D51) — don’t build new features on it.
  • The live editor canvas renders embed/custom_code nodes live: embeds mount their real iframe on the canvas; custom_code renders 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 (renderEmbeddedNodeHtml in frontend/src/lib/canvas-embed-render.tsserializeNodeTree, attached as _matrizRenderedHtml in editorStore.updateAllPreviews) and mounted by preview.html — there is no ES5 fork of normalizeEmbedUrl/escapeSrcdoc (the G3 parity gate still holds). custom_code stays opaque-origin sandboxed (allow-scripts allow-forms, never allow-same-origin) even on the canvas. In edit mode the child iframe is pointer-events: none so canvas selection still works.
  • db/*.sql snapshots and older docs can be stale; verify against packages/schema and the route files.
  • Importer tests auto-collect — never hand-list them. The 50 apps/bondlayer-worker/src/test_*.py files are bare python3, and each ends with sys.exit(run(globals())) (_testkit.py), which collects every module-level test_* 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 in main()/at import time keep their own entry points.
  • custom_head_css / custom_body_js are LIVE, not inert passthrough. Bondlayer’s project-level webHeadStyles/webBodyScripts land in target settings and are emitted at publish as <style data-matriz="custom"> (workers/src/lib/page-render.ts:378, and the standalone renderer in packages/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 in packages/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-name is 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 (bgblur 33, square 25) — fanning out is the author’s intent, not a bug. The exception is unrenamed defaults: Text matches 1538 nodes, so a rule reaching a default name is almost always a mistake. Never assume a name is unique.
  • 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 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.