Skip to content

Agent reference — the long form behind `CLAUDE.md`

Status: reference — 2026-09-23. Class-2 durable reference (docs/DOCS-POLICY.md). CLAUDE.md is loaded into every agent session and subagent, so it keeps only the rules; this file keeps the rationale, history and walkthroughs those rules were distilled from (moved here verbatim when CLAUDE.md was slimmed, 2026-09-23 — plans/HARNESS-MODERNIZATION.md §1.3). The full environment-variable catalogue lives in ARCHITECTURE.md §4. When this file and CLAUDE.md disagree, fix whichever is wrong; when either disagrees with code, code is the final authority.

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.

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.

2. The Bondlayer importer’s host (Render → Cloudflare Container)

Section titled “2. The Bondlayer importer’s host (Render → Cloudflare Container)”

The Python Bondlayer importer is NOT archived. apps/bondlayer-worker/ is a Python service (one Dockerfile) 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.

  • Live host: the matriz-import-runtime Cloudflare Container (apps/bondlayer-worker/edge/, same Dockerfile as the retired Render service), deployed by deploy-import-runtime.yml on merge to apps/bondlayer-worker/.
  • Cutover: the D:importer-containers cutover (plans/IMPORTER-CONTAINERS-MIGRATION-SPEC.md) flipped IMPORT_RUNTIME to "container" on skyfall-api 2026-09-02.
  • Render (skyfall-keru.onrender.com) is no longer the importer host and no longer serves traffic; it is a retired fallback path only. Its wrangler.jsonc vars are kept only as an inert rollback path (IMPORT_RUNTIME on skyfall-api, workers/src/lib/import-runtime.ts). Decommissioning Render itself (Pass F: suspend/delete the service, retire the three onrender.com vars, rotate IMPORT_WORKER_TOKEN) is a separate open owner action — see plans/OWNER-OPS.md.
  • A path that never existed: 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-import-runtime.yml and the docs sites; deploy-api.yml/deploy-editor.yml are manual-only since 2026-09-23 because the Cloudflare Git integrations deploy those two). pnpm ci:local is still the pre-push gate for everything it covers. Actions stopped again 2026-09-23 (spending limit / minutes exhausted); jobs now pick a runner by tier (CI_CHECKS_RUNS_ON / CI_HEAVY_RUNS_ON / CI_DOCKER_RUNS_ON, secrets on CI_RUNS_ON only) — D:ci-runner-tiers, OWNER-OPS § 2026-09-27.

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; within 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)

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. test_members_from_xlsx.py needs openpyxl (pip3 install openpyxl) and does NOT skip: without it the whole gate stops at “python importer tests” with a ModuleNotFoundError, which reads like a real failure. Both are pinned in apps/bondlayer-worker/requirements.txt and installed by importer-ci.yml, so this is a local-environment gap only — pip3 install -r apps/bondlayer-worker/requirements.txt covers both at once.

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 importer deploys via deploy-import-runtime.yml (§2).

CI (.github/workflows/) — LIVE since 2026-08-02 — see “CI: GitHub Actions is LIVE again” above; pnpm ci:local is still the pre-push gate. 10 workflows: frontend-ci.yml, workers-ci.yml, packages-ci.yml, importer-ci.yml, db-guard.yml (schema-drift gate) are checks; deploy-docs-product.yml, deploy-docs-dev.yml, deploy-import-runtime.yml deploy on push to master (path-filtered, + manual dispatch); deploy-api.yml (Worker) and deploy-editor.yml (Pages) are manual-dispatch only since 2026-09-23, because Workers Builds / the Pages Git integration deploy those on every master push. All need the CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID repo secrets.

4. Deployed hosts and frozen skyfall* names

Section titled “4. Deployed hosts and frozen skyfall* names”

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

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.

5. UI Lab — visual inspection without auth or DB (/ui-lab)

Section titled “5. 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 write through slot_values via the ordinary attributes path (PropertyCommands.updateSlotValues → NodeWriteAPI.update). There is exactly one authoritative path — the Y.Text CRDT seam that used to sit in front of it (routeTextWrite) was deleted (D:ytext-deletion): it never had a caller construct a session, so it always returned false and the REST path always ran. See docs/design/COLLAB-YTEXT-ACTIVATION.md for why, and for what a future real-time text feature should look like instead.

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.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. Always subscribe via selectors: useEditorStore(s => s.selectedElementId). (There is no presenceStore — it was deleted with the presence UI; nothing in frontend/src/ references it.)

  • History (2026-06-25 through 2026-09-27) is frozen and chunked under decisions-logs/archive/ (D:decisions-logs) — split by scripts/decisions/split.mjs, ~25 entries per chunk, byte-exact (its _manifest.json pins a sourceSha256; decision-number-guard.test.ts re-checks it every run). decisions-logs/INDEX.md is generated from the archive (scripts/decisions/index.mjs) — grep that instead of a chunk file, and never Read a chunk whole. DECISIONS.md at the repo root is now a ≤30-line stub kept only so old links resolve.
  • Why this replaced D-number claiming: the old scheme (claim a number late, at the final pre-merge sync, cite by slug until then) raced repeatedly anyway — parallel sessions all appended to one file, so a number picked at merge time could already be stale by the next merge, minutes later (it happened 3× in one branch, D68→D69→D72, and twice more the week the original guard was written — see its old header, preserved in decisions-logs/archive/). Four historical duplicates from that era — D41/D50/D51/D52 — are grandfathered permanently; they predate the fix and are cited too widely to renumber.
  • The fix: one file per decision, slug-only, forever. A new decision is decisions-logs/entries/<YYYY-MM-DD>-<slug>.md (template: entries/_TEMPLATE.md, heading # D:<slug>), cited as D:<slug> — no number is ever claimed again. Two parallel PRs adding different decisions now touch different files: nothing to conflict over, nothing to renumber. Old, already-merged decisions keep their number and are cited bare (D50, D56) as always. workers/test/node/decision-number-guard.test.ts fails the build on a slug collision (with another entries/*.md or an archived decision), a filename/heading mismatch, or any new numbered ## D<n> heading anywhere — that scheme doesn’t get resurrected to make a build pass.
  • 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.ts → serializeNodeTree, 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.
  • pnpm db:migrate aborting with ... already exists? Prod carries TWO __drizzle_migrations tables — drizzle. (the real one drizzle-kit reads) and public. (a phantom nothing reads), and the migrator’s cursor is just the single row with the max created_at, so one row written to the wrong table re-runs that migration and every one after it. Hand-applies MUST schema-qualify drizzle."__drizzle_migrations". Diagnosis + repair recipe: db/MIGRATIONS.md → “Two ledger tables”.
  • data-member-visible is a whitespace-separated, AND-combined token list, and an unknown token HIDES (D:member-auth-blocks): authed|signed-in, anon|signed-out, active, pending, role:<r>, !role:<r> (not a member with that role — signed-out included; D:bondlayer-member-state), provider:google|magic-link, join:open|invite|approve. Imported Bondlayer _state session gates (UserLogged/UserRole/…) become these tokens (importer member_state_mapper.py, render-time legacy normalizer + repair lib workers/src/lib/member-state-migrate.ts, one shared table in member-state-conditions.ts) — _state is never evaluated as a CMS field. The grammar lives in MEMBER_RUNTIME_SOURCE (packages/site-render/src/interactions-runtime.ts) AND is mirrored by hand in frontend/public/preview.html (applyMemberPreview) — change both, and keep members-library.verify.test.ts (which validates every authored value) green. provider:/join: tokens are always visible in the editor (no config fetch on the canvas).
  • PGlite suites boot from ONE migrated snapshot per run, not 38 migrations per file (D:pglite-snapshot). vitest.node.config.ts’s globalSetup migrates once and dumps the data dir to workers/node_modules/.cache/pglite-snapshot/<hash>.tar (hash = every drizzle/*.sql + PGlite version); makePgTestDb() loads it in ~1–2 s instead of initdb + migrations (~9 s). Editing a migration invalidates it automatically. pglite-snapshot-fidelity.test.ts pins that the loaded catalog equals a freshly migrated one — if you change how migrations are applied in test/node/helpers/pg-test-db.ts, bump SNAPSHOT_FORMAT in helpers/pglite-snapshot.ts. makePgTestDb({ stopBefore }) still migrates in-process (it needs a partial schema). A database made inside a test or beforeEach is closed automatically when that test finishes (onTestFinished); never keep one across tests. Unclosed per-test PGlite heaps (~250 MB each) got forks OOM-killed (“Worker exited unexpectedly”) before this.
  • 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). On both surfaces it comes AFTER Matriz’s floor rules (base/font-inherit/sizing-reset on publish, preview.html’s static <style> on the canvas), so it wins specificity ties against them (D:site-css-after-floors, site-css-order-parity.test.ts). Anything that migrates rules out of that blob MUST remove them from it; leaving them in double-applies.
  • A text node’s words live in slot_values.text, not content (D:text-slot-canonical). Every renderer reads the slot first; dom_nodes.content is a mirror a DB trigger keeps (workers/drizzle/0044_text_slot_canonical.sql: slot → content on insert/slot change, a content-only UPDATE is copied into the slot; skips component instances and CMS-bound slots). Write the slot (PropertyCommands.updateSlotValues/updateContent, or the Dev MCP’s update_text_node / replace_text_across_site) — never raw SQL on dom_nodes.content. The batch save route never overwrites content on a slot-owned row (a full-row flush can carry a stale copy).
  • 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.

Appendix — CLAUDE.md sections as they read before the 2026-09-23 slim

Section titled “Appendix — CLAUDE.md sections as they read before the 2026-09-23 slim”

CLAUDE.md now carries condensed versions of these. The full wording is kept here so no fact was lost; the importer, CI and deploy rows of the architecture table are in §2–§4 above.

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/intent-system/MCP-CONNECT-RUNBOOK.md + MCP-OAUTH-SETUP.md Connecting external agents over MCP — credentials/URLs runbook, then the OAuth door’s setup + scope mechanics.
docs/architecture/COLLECTION-CONTROLS.md The data-mf-* collection-control vocabulary (search/filter/sort/pager): which surface honours each marker, why edit mode is inert but Preview is live, and how to debug an imported control that “does nothing”.
docs/Research/BONDLAYER-*.md Competitive capture of Bondlayer’s authoring surfaces (research, not spec) — see the corpus map in plans/BONDLAYER-FOLLOWUPS-WORKSTREAM.md.
design/figma/FIGMA-SYNC.md Figma mirror of the editor UI (file bZ17CtfEJ11rcV8UHNGKvh) — sync contract + node/variable ID map. Any change to editor tokens (frontend/src/index.css, tailwind.config.js), chrome layout, or lucide icons must be synced BOTH ways via the figma-editor-sync skill (.claude/skills/figma-editor-sync/). Code is source of truth; edit Figma nodes in place by ID, never recreate.
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 (43 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 — IntentRoomDO (IDR poke channel, D60/D61)
Shared packages @matriz/schema, @matriz/site-render (renderer shared by editor preview AND publish — preview↔published parity), @matriz/import (source-blind import adapters)

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.

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