Skip to content

AI system

Status: current — 2026-09-13 · Owner: AI/agent surface

The full AI surface: the MCP server + BYOK/native HTTP routes agents and the editor call, the system-context assembly that grounds every generation in the site’s own live design system, the mutation-tier safety guard, the per-element AI menu in the editor UI, and the marketing/CRM email system it shares an approval discipline with. Companion reading: plans/AI-GROUNDING-WORKSTREAM.md (the S-series slices this doc explains), plans/CONTENT-AI-WORKSTREAM.md (auto-translate), and plans/MARKETING-CRM-INTEGRATION-WORKSTREAM.md (the email catalog + the two open forks this doc names).


1. The aiRouter surface (workers/src/routes/ai.ts)

Section titled “1. The aiRouter surface (workers/src/routes/ai.ts)”

All routes are mounted under /api/ai, run authMiddleware, and (except /marketing, which is project-scoped) run projectAccessMiddleware to resolve a targetId. Every route uses getScopedDB(c) so it rides the RLS tenant connection once enforcement is on (see AUTH-AND-TENANCY.md).

JSON-RPC 2.0 over HTTP (handleMcp, workers/src/mcp/jsonrpc.ts), scoped to one target by projectAccessMiddleware. GET /mcp returns an explicit 405 — there is no SSE stream on this server, Streamable HTTP only. See §3 for the tool list.

write_component is retired from this tool list (per D62, extending the D51 Intent-Driven-Rendering deprecation): agents compose UI via emit_ui_intent instead. The validate-only writeComponent function still exists in workers/src/mcp/tools.ts — it backs the legacy BYOK /generate edit path (§2) and its pure validateProposed core is reused as the policy layer inside emit_ui_intent itself — but no MCP client can call it directly anymore. Do not add new MCP tools that call it — build on emit_ui_intent.

POST /generate — BYOK/native model draft

Section titled “POST /generate — BYOK/native model draft”

The one chokepoint for “turn an instruction into a validated draft,” with three exits:

  1. mode:'text' (D:ai-element-menu, E3-B4 “Rewrite copy”) — a validate-only prose rewrite. It has its own contract (ai/text-draft.ts) because ProposedNode is styles/props-shaped and pushing a rewrite through a styles field “would be a lie to every reader of it” (the route’s own comment). Every refusal path — no nodeId, the node not found, or the node having no rewritable text of its own (CMS-bound slots are excluded by readNodeTextSurface) — runs before resolveNativePath, so a refused rewrite spends nothing (refusal-before-burn).
  2. New node + targetPageId — the draft becomes a Component.Compose intent through the full emit_ui_intent stack, queued in the editor’s Insertion Queue for human review (the IDR path, D62).
  3. nodeId edit, or no targetPageId — the legacy validate-only writeComponent chokepoint: returns { ok, violations, draft }, never persists. Mutation intents are reserved for IDR v2, so edits stay on this path for now (only new nodes go through emit_ui_intent).

An optional embed block (provider/apiKey/model) runs a best-effort pgvector retrieval — embed the instruction, pull the top-5 similar existing styles/components (retrieveSimilar), and append them to the steering context. A retrieval failure never blocks generation.

GET /native-status, POST /provision — native AI + capability provisioning

Section titled “GET /native-status, POST /provision — native AI + capability provisioning”

Native path (workers/src/ai/native.ts, D:native-ai): the in-product alternative to BYOK keys, running on the account’s own Workers AI binding (env.AI). resolveNativePath() in ai.ts is shared by /generate, /provision, /brand, and /translate — it treats provider:'native' (or no provider/apiKey at all) as a request for the native path, 503s if env.AI isn’t bound, and otherwise meters a fixed-window daily allowance via the CONFIG KV (checkNativeAiAllowance/spendNativeAiAllowance, lib/rate-limit.ts) — a brake against the finite Workers AI free-plan neuron allocation, not a billing mechanism. Default limit: DEFAULT_NATIVE_AI_DAILY_LIMIT = 50 per day (env override: WORKERS_AI_DAILY_LIMIT), one account-wide bucket (meters the owner’s plan, not per-user). Over-allowance → 429 with Retry-After and a BYOK hint. Spend is recorded only when a call will actually reach the binding — the same refusal-before-burn discipline as /generate’s text mode. Default model: @cf/meta/llama-3.3-70b-instruct-fp8-fast. GET /native-status is read-only (configured?, model, today’s used/remaining/reset) and never spends allowance.

POST /provision (Capability.Provision intent) turns a natural-language instruction into a plan of 1–5 { intentType, payload } steps (generateCapabilityPlan), each of which is emitted through the same emitUiIntent pipeline as everything else — so the model can only queue validated intents for human approval; every step is its own review card. Partial success is reported per intent (queued vs rejected with pushback), so a caller/model can retry just the failed steps. 200 when at least one card queued; 422 only when the whole plan bounced.

POST /brand — cold-start re-theme (D:brand-first-generation, S6)

Section titled “POST /brand — cold-start re-theme (D:brand-first-generation, S6)”

API/MCP-only today — there is no frontend UI button that calls this route. It is reachable only by an agent/script calling the HTTP endpoint or MCP directly; do not describe it to users as something they can click.

When the S1 system snapshot (§4) says a site is still mostly wearing its seeded token preset (assessSystemMaturity), this proposes a brand — Token.Update cards re-theming tokens still graded preset, plus Token.Create for genuinely missing vocabulary — before anything is composed against it. A site with emptySystem (no design tokens at all) gets a 409 telling the caller to seed a token preset first; a site that already has an authored brand (!maturity.cold) gets a 200 with an explanatory message and no cards — grading maturity per token is the entire point of the route, and it says so honestly rather than proposing changes nobody asked for. A token graded custom/modified is dropped from the plan (reported in skipped), never emitted — decision 3 of the underlying spec.

The mutation machinery is used honestly, not bypassed: calling this endpoint with a brief is the user’s explicit request, so emitUiIntent is called with explicitMutationRequest: true — and that is the only concession. Every card still runs the full emit pipeline, still carries previousValue, still meets the before-value guard at emit/accept/apply (§5), and still requires a human to accept it one card at a time. No bypass, no batch-apply, no auto-accept.

POST /marketing — copy drafting over an email template

Section titled “POST /marketing — copy drafting over an email template”

Project-scoped (not target-scoped, matching email_templates), gated by userCanAccessProject on the row’s project. Two modes: mode:'subject' (default — N subject + preview-text candidates) or mode:'copy' (rewrite the template’s text blocks). Validate-only like every other draft route: nothing is persisted; a human approves the draft and it lands through the ordinary PATCH /api/email-templates/:id write.

POST /translate — CMS auto-translate (D:cms-auto-translate)

Section titled “POST /translate — CMS auto-translate (D:cms-auto-translate)”

Known documentation gap, stated here on purpose: this shipped per plans/CONTENT-AI-WORKSTREAM.md but as of 2026-09-13 is not mentioned in STATUS.md. If you’re auditing what STATUS.md claims against what’s actually live, this route is a real gap between the two — fix STATUS.md’s AI section or flag it, don’t assume the absence means it isn’t shipped.

{ itemIds, locales?, fields?, overwriteExisting?, provider?/apiKey?/model? } — gated by content:write (the same capability PATCH /api/content-items/:id sits behind, so a viewer cannot translate). Everything that can refuse the whole request runs before resolveNativePath (an unknown locale, an over-cap batch of more than MAX_TRANSLATE_ITEMS items) — refusal-before-burn again. For a bulk run, the same daily native allowance is metered once per model call, not once per request — the route’s own comment explains why: a bulk translate is many model calls, so metering only the request would under-report native spend by an order of magnitude. The first call is “prepaid” from the allowance unit resolveNativePath already spent for the request, so it isn’t double-charged.

It never publishes. The store handed to the job (createTranslateStore) can write data and create a draft version row — that is its entire vocabulary. 200 when at least one entry was written; 422 when none were (the same partial-success contract /provision uses).

/embed populates pgvector embeddings for the site’s styles + global components (explicit BYOK call, since it costs tokens). /ruleset exports the constraint/token/schema ruleset as Markdown (AGENTS.md-shaped) via buildAgentsMd — the same tokens/constraints/content-schema an external coding agent would want as ground truth.


2. System-context assembly (workers/src/ai/system-context.ts)

Section titled “2. System-context assembly (workers/src/ai/system-context.ts)”

The doc-comment framing is worth repeating verbatim: “the formal design_systems row is provenance, not the system. The system a model must honor is emergent.” buildSystemSnapshot(db, targetId) builds that emergent snapshot once per call, and renderSystemContext(snapshot) renders it as the steering context every AI door shares (today: /generate’s assembleContext path; MCP read tools can adopt the snapshot shape later).

Token grading — every design token gets one of three grades:

  • custom — no source_preset provenance: hand/AI/import-authored. The strongest brand signal.
  • modified — seeded by a preset, but the stored value no longer matches the seed (compared via canonical, sorted-key JSON so jsonb key order can’t cause a false “changed”).
  • preset — still exactly the seed value. Template inventory: shown as a name only, heavily compressed, so it can’t drown the real signal.

Both custom and modified tokens are rendered WITH value + usage count; preset tokens are name-only. The context text states outright: “The customized tokens below ARE the brand: prefer them over anything generic… Do NOT change an existing value unless the user explicitly asked you to.”

The rendered context layers vocabulary → grammar → standing (the S1/S5/S6 ordering from AI-GROUNDING-WORKSTREAM.md): the token/style/component vocabulary (S1, this section) comes first, then composition grammar — the assembly exemplars (S5, workers/src/ai/assembly-exemplars.ts) — few-shot compositions derived from the target’s own pages (not a generic component list), selected by greedy set-cover over distinct vocabulary references so a compact, vocabulary-dense section beats a large sparse one. Then S6’s cold-start brand proposal (§1, /brand) sits downstream as the “if there’s no brand yet, propose one first” standing layer. All three are capped (tokens, styles, components, exemplar count/depth/node caps) with the truncation stated out loud in the rendered text (“MORE VOCABULARY EXISTS … — search_system finds the rest”) rather than silently dropped.

search_system retrieval (D:system-retrieval-tool)

Section titled “search_system retrieval (D:system-retrieval-tool)”

An MCP tool (§3), not a bigger context dump: lexical ranking only — exact match first, then bidirectional-prefix boost, then Dice-coefficient fuzzy (reusing the same matching machinery emit_ui_intent uses to suggest alternatives on a validation failure). Results carry the exact reference form emit_ui_intent expects (token:<name>, a styleId, a component name) so a model can use them directly with no reformatting.

The pgvector blend is a recorded deferral, not an oversight — cite plans/AI-GROUNDING-WORKSTREAM.md §S4 directly: MCP callers have no way to supply a query-side embedding key, and there is a genuine dimension mismatch, not just an unscheduled task — stored embeddings from BYOK providers are 1536-dim, while the keyless Workers AI embedding models are 768-dim. Blending the two would mix incompatible vector spaces. This revisits only once one embedding model covers both the keyless and BYOK paths.


The MCP emit_ui_intent tool distinguishes additive intents (everything in INTENT_TYPES except two) from the mutation tier: MUTATION_INTENT_TYPES = Token.Update and Style.Update (workers/src/intent/schema.ts, re-exported from @matriz/schema/intent-mutation). isMutationIntent(type) is the single predicate everything below relies on.

A mutation payload must carry the value it believes is currently live — previousValue (Token.Update) or previousStyles (Style.Update, one entry per changed property). checkMutationGuard(db, targetId, intentType, payload) (workers/src/intent/mutation.ts) re-reads what is actually live and refuses when the two disagree, naming the drift in the exact form a human and the model both read: "--color-primary changed since this was proposed: expected #3B82F6, found #1D4ED8".

Why it exists, quoted from the module: “an intent can sit in the review inbox for minutes or days while a human keeps working. Applying a stale proposal would silently clobber their edit — the one failure mode a ‘the AI proposes, the human approves’ system must never have.”

The guard runs at three points, sharing this one function so the rule has exactly one definition:

  1. Emit — fails fast; a proposal built on a stale belief never reaches the review queue (the model gets pushback it can self-correct against).
  2. Accept (POST /api/intents/:id/claim) — the durable, server-side check at the moment a human’s decision is recorded.
  3. Apply — client-side, immediately before the write, in frontend/src/lib/operations/IntentCommands.ts. This is called out as the authoritative check (execution is client-side under the user’s own session — spec §4, “User-as-Middleware”); on refusal the claim is released and the intent returns to the inbox with the drift named.

A mutation is never implicit: emit_ui_intent’s own tool description states it must be proposed “ONLY when the user explicitly asked to change that existing value,” and the caller must additionally pass explicitMutationRequest: true — without it, the emit is refused outright. Renaming or deleting a token/style is not available through this tool at all.

Token.Update resolution (resolveUpdatedToken) is deliberately canonical-name only (optionally narrowed by category): the client-side apply path re-resolves the token from the editor’s own token list, which carries no aliases, so accepting an alias name here would queue a card that can never actually apply. An alias hit is reported separately so the refusal names the canonical token instead of falsely claiming it doesn’t exist. Style.Update is scoped exactly as the human write path scopes it (target_id match only — a NULL-target global style is never a valid subject), for the same reason: admitting one would queue a card that passes every earlier check and then 404s at apply.


One shared item list, three renderings (D:ai-element-menu, frontend/src/components/canvas/element-ai-actions.ts): the QuickToolbar’s “✦ AI” dropdown, the DOM-tree row’s right-click menu, and the canvas right-click relay all render the exact same ELEMENT_AI_MENU_ITEMS list — keeping the labels and preset instructions in one file is what makes these three surfaces one menu rather than three that happen to look alike.

Four items, in order:

  1. Edit with AI… — free-form instruction → the /generate draft flow (§1), reviewed before anything applies.
  2. Restyle to brand — a fixed instruction (RESTYLE_TO_BRAND_INSTRUCTION) that only became meaningful once the grounding workstream’s S1 put the target’s customized tokens into the /generate context: “the site’s design tokens” is a real, brand-specific vocabulary server-side, not a generic phrase. Structure/content are pinned in the instruction so the draft stays inside what the dialog may apply (styles + props only).
  3. Rewrite copy… — mode:'text' (§1). Refuses when the element’s text comes from a CMS field or a repeater binding — the dialog resolves text ownership (resolveTextOwnership in ElementAiDialog.tsx) before the Run control even exists, and names the actual owner rather than presenting a disabled row that teaches nothing. An unbound literal inside a collection repeater is still the node’s own text and is rewritable — only CMS-bound content is refused.
  4. Promote to shared style — not an AI action; deliberately kept in this same list as “the system-building co-tenant that keeps the menu useful when the AI path is unavailable” (allowance spent, no binding).

lens-aware apply — see ElementAiDialog.tsx for how the applied draft is scoped to the current viewport/breakpoint lens rather than clobbering other breakpoints’ overrides.


packages/email-render/src/catalog/{visitor-auth,commerce,lifecycle,forms}.ts — 46 templates total: 17 (visitor-auth.ts) + 18 (commerce.ts) + 8 (lifecycle.ts) + 3 (forms.ts). Every entry declares merge fields (some required) and a kind (transactional vs marketing) that governs whether a footer/unsubscribe link is emitted.

The single send seam: sendCatalogEmail (workers/src/lib/catalog-send.ts)

Section titled “The single send seam: sendCatalogEmail (workers/src/lib/catalog-send.ts)”

The problem it fixes, from the module’s own header: before this existed, a transactional email’s markup lived wherever the feature that sent it happened to be — e.g. a hardcoded magicLinkHtml() with a literal #111 button and the literal string “Sign in,” ignoring the project’s brand entirely and impossible to preview or edit. Every new feature needing to mail someone would have grown its own copy of that problem.

Resolution order for a given catalogId + project:

  1. The project’s own edited copy wins, always — an email_templates row whose tree.catalogId matches (stored inside the tree jsonb, not a column, so no migration was needed). This is what makes “predesigned” a starting point rather than a cage.
  2. The catalog default, instantiated fresh against the project’s resolved brand (resolveCatalogBrand) when no project copy exists.

A missing required merge field refuses the send outright — missingRequiredMerge runs before any brand query, mailer, or network call. The module states the reasoning directly: “a sign-in email whose link renders empty is worse than no email: the recipient stops waiting for the real one, and the failure is invisible to everyone.” The caller gets { ok:false, missing:[...] } to log loudly rather than a silently broken send.

Two known open forks — do not build around them unknowingly

Section titled “Two known open forks — do not build around them unknowingly”

These are specced, not built gaps named directly in plans/MARKETING-CRM-INTEGRATION-WORKSTREAM.md; anyone extending this system should read that plan’s §2 and §5 before assuming either problem is solved.

  1. The contact-identity fork. A person interacting with one site can exist as up to three unjoined rows with nothing connecting them: site_visitors (+ site_members, keyed by (target_id, email)), a content_items row in an audience collection (keyed by nothing but an email field inside a jsonb blob), and an order row in the orders collection (same — an email field in jsonb, no key). Consequences already reachable today: a campaign cannot target “members who have ordered”; an abandoned-cart flow cannot tell a member from a guest; a suppression applies to an address but not to the member behind it; and the shipped-but-unwired shop.guest-claim-account template has no code path to actually attach an order to the member who later claims it. The plan poses this as an owner decision between (a) making site_visitors the spine (cheap, but breaks a project-wide audience spanning two targets) and (b) a new project-scoped contacts table (correct at the tenant level, but a migration + backfill) — recommended: (b), but unresolved as of this writing.
  2. shop.order-confirmation’s order_url need. This template requires an order_url merge field, but Matriz hosts no order-status page. lib/order-receipt.ts builds the link from the target’s canonical host plus settings.commerce.orderStatusPath (default /orders) — an unpublished store gets no automatic receipt at all; it logs the reason instead of mailing a dead button. Closing this needs either a hosted order-status route or a documented builder-made-page convention — an owner decision, not a code fix.

(The remaining 44 catalog entries beyond the two wired triggers are instantiable/editable/previewable/sendable today but not yet auto-triggered by anything — that’s Phase 4 of the same plan, deliberately not faked.)


CapabilityManifest (workers/src/capabilities/catalog.ts) — a v1 subset of a future ModuleManifest — describes a drop-in backend capability: id (also the Capability.Provision payload’s capabilityId and the targets.settings.modules[id] key), label, summary, a lucide icon name, optional requires gates (visitorAuth/email/payments), an optional planGate entitlement key, and one or more collections (ContentTypePreset[] — the CMS collection(s) provisioning creates). A capability can be kind:'form' (e.g. bookings) — provisioning then mints a paired forms row so a native form can POST to /api/m/:targetId/forms/submit/:slug with no client JS at all.

Two ways to provision the same catalog: the AI path (Capability.Provision intent via /provision or MCP emit_ui_intent, §1) and the human path, frontend/src/components/settings/CapabilitiesSection.tsx — browse the catalog, see what’s enabled on the current target (targets.settings.modules[id]), and enable/disable directly. Disabling flips the flag but keeps the data — provisioning is not destructive in reverse.


  • workers/src/routes/ai.ts — the router this doc walks through top to bottom; read it alongside this doc, its own comments are extensive.
  • workers/src/ai/{native,system-context,brand-proposal,assembly-exemplars,translate}.ts
  • workers/src/mcp/{jsonrpc,tools,content-tools}.ts — the MCP tool definitions and the three content-tool invariants (draft-only writes, canAccessCollection on every call, no leaked collection counts).
  • workers/src/intent/{schema,mutation}.ts — the intent type catalog and the before-value guard.
  • packages/email-render/src/catalog/*.ts, workers/src/lib/catalog-send.ts.
  • plans/AI-GROUNDING-WORKSTREAM.md, plans/CONTENT-AI-WORKSTREAM.md, plans/MARKETING-CRM-INTEGRATION-WORKSTREAM.md — the living specs behind the S-series slices, auto-translate, and the email/CRM system respectively.