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.
Start here
Section titled “Start here”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.
Agent tooling — find before you read
Section titled “Agent tooling — find before you read”Stop at the first rung that answers:
- 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 readGRAPH_REPORT.md(usegraphify god-nodes). - 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 worktreeguarda-serena.shblocks it) and often disconnects: then graphify, then Grep. - Grep / Glob, then Read with
offset/limit(edits and tests still need the file). Never read adecisions-logs/archive/chunk whole — grepINDEX.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 (notsleep) ongraphify-out/graph.json(loggraphify-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.shdoes setup; locally runscripts/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 liteorPONYTAIL_DEFAULT_MODE=offturns it down.
Spawning agents — keep the token budget
Section titled “Spawning agents — keep the token budget”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|pathbefore Grep/Read; Read withoffset/limit; runpnpm ci:local --changed(and any PGlite suite) in the background; report conclusions withfile: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 map
Section titled “Doc map”| 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.
CI and deploy
Section titled “CI and deploy”Reading Actions, reruns, merge-when-green and job timings: matriz-ship-pr.
pnpm ci:localIS 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; aModuleNotFoundError(openpyxl) is a local-environment gap, not a real failure. - Deploys:
skyfall-apivia Cloudflare Workers Builds, the editor via Pages Git integration, the importer viadeploy-import-runtime.yml.deploy-api.yml/deploy-editor.ymlare 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-28CI_CHECKS_RUNS_ON=vps(checks),CI_HEAVY_RUNS_ON/CI_DOCKER_RUNS_ON=oracle(test shards, frontend build, importer image);CI_RUNS_ONunset, so deploys and workersliverun onubuntu-latest, blocked by GitHub billing. Never setCI_RUNS_ONto a self-hosted label — it’s every tier’s fallback and the only setting secret-bearing jobs read. Runner setup lives inno-tag/vps-infra(bootstrap/runner-hooks/LEIA-ME.md,MAQUINAS.md,ORACLE-RUNNER.md) — don’t restate it here.
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 hooks:install # once per clone: the pre-push ci:local hookpnpm ci:local # everythingpnpm 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.shpnpm 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 suiteEnvironment
Section titled “Environment”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 |
Architecture
Section titled “Architecture”| 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-srcis'none'| sha256 hashes | (ONLY a page rendering aspam.turnstileform)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 rawiframecoerces todiv.custom_codeis an opaque-originsrcdociframe,sandbox="allow-scripts allow-forms";allow-same-originis unrepresentable and CI-gated (sandbox-gate.test.ts) — on the canvas too.
Stores: 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, one file each, no numbers: copy
decisions-logs/entries/_TEMPLATE.mdtodecisions-logs/entries/<YYYY-MM-DD>-<slug>.md(heading# D:<slug>), cite it asD:<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.tsfails 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.ymlfails 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 inpackages/site-render/src/token-presets.ts(D54/D55)
Gotchas
Section titled “Gotchas”Rationale for each: CLAUDE-REFERENCE §10.
frontend/src/lib/postgrest.tsis a legacy-named barrel overlib/api/; there is no PostgREST.- Canvas ↔ iframe
postMessage: validateevent.origin. workers/src/worker.tsis the deploy entry; tests importindex.ts— don’t moveagentsimports into index.ts.- Uploads go through
/api/assets/sign;cdn_urlis derived server-side (D38) — never construct it client-side.assets.tieris server-side too (D:one-cdn-url): adesignasset 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_componentis slated for deprecation (D51) — build nothing new on it. - The canvas renders
embed/custom_codefrom@matriz/site-rendermarkup — never forknormalizeEmbedUrl/escapeSrcdoc. db/*.sqlsnapshots and older docs can be stale; verify againstpackages/schemaand routes.- Hand-applied migrations MUST schema-qualify
drizzle."__drizzle_migrations"(db/MIGRATIONS.md→ “Two ledger tables”). data-member-visibleis an AND-combined token list and an unknown token HIDES (D:member-auth-blocks); its grammar is inMEMBER_RUNTIME_SOURCEAND hand-mirrored infrontend/public/preview.html(applyMemberPreview) — change both, keepmembers-library.verify.test.tsgreen. Imported_stategates become these tokens;_stateis never evaluated as a CMS field.- PGlite suites boot from one migrated snapshot (
D:pglite-snapshot): changing howpg-test-db.tsapplies migrations means bumpingSNAPSHOT_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_jsare 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, notcontent(D:text-slot-canonical) — write the slot (PropertyCommands.updateSlotValues/updateContent, Dev MCPupdate_text_node), never raw SQL ondom_nodes.content. data-bl-nameis not unique — assume fan-out; a rule on an unrenamed default (Text) is almost always a mistake.
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 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.
Coordination: clockwork
Section titled “Coordination: clockwork”- 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.
Research profile
Section titled “Research profile”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.