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), andplans/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).
POST /mcp — the MCP server
Section titled “POST /mcp — the MCP server”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:
mode:'text'(D:ai-element-menu, E3-B4 “Rewrite copy”) — a validate-only prose rewrite. It has its own contract (ai/text-draft.ts) becauseProposedNodeis 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 — nonodeId, the node not found, or the node having no rewritable text of its own (CMS-bound slots are excluded byreadNodeTextSurface) — runs beforeresolveNativePath, so a refused rewrite spends nothing (refusal-before-burn).- New node +
targetPageId— the draft becomes aComponent.Composeintent through the fullemit_ui_intentstack, queued in the editor’s Insertion Queue for human review (the IDR path, D62). nodeIdedit, or notargetPageId— the legacy validate-onlywriteComponentchokepoint: 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 throughemit_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.mdbut as of 2026-09-13 is not mentioned inSTATUS.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).
POST /embed, GET /ruleset
Section titled “POST /embed, GET /ruleset”/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— nosource_presetprovenance: 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.
3. The mutation tier / before-value guard
Section titled “3. The mutation tier / before-value guard”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:
- Emit — fails fast; a proposal built on a stale belief never reaches the review queue (the model gets pushback it can self-correct against).
- Accept (
POST /api/intents/:id/claim) — the durable, server-side check at the moment a human’s decision is recorded. - 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.
4. The AI element menu (editor UI)
Section titled “4. The AI element menu (editor UI)”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:
- Edit with AI… — free-form instruction → the
/generatedraft flow (§1), reviewed before anything applies. - 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/generatecontext: “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). - 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 (resolveTextOwnershipinElementAiDialog.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. - 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.
5. Marketing & CRM: the email catalog
Section titled “5. Marketing & CRM: the email catalog”The catalog (@matriz/email-render)
Section titled “The catalog (@matriz/email-render)”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:
- The project’s own edited copy wins, always — an
email_templatesrow whosetree.catalogIdmatches (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. - 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.
- 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)), acontent_itemsrow in an audience collection (keyed by nothing but anemailfield inside a jsonb blob), and an order row in theorderscollection (same — anemailfield 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-unwiredshop.guest-claim-accounttemplate 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) makingsite_visitorsthe spine (cheap, but breaks a project-wide audience spanning two targets) and (b) a new project-scopedcontactstable (correct at the tenant level, but a migration + backfill) — recommended: (b), but unresolved as of this writing. shop.order-confirmation’sorder_urlneed. This template requires anorder_urlmerge field, but Matriz hosts no order-status page.lib/order-receipt.tsbuilds the link from the target’s canonical host plussettings.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.)
6. Capability provisioning
Section titled “6. Capability provisioning”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.
Where to look next
Section titled “Where to look next”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}.tsworkers/src/mcp/{jsonrpc,tools,content-tools}.ts— the MCP tool definitions and the three content-tool invariants (draft-only writes,canAccessCollectionon 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.