Generated Frontend Surface Contract¶
This document defines the canonical frontend scope for generated Mozaiks apps. For the validation model, see UI System Quality Gates.
The key rule is simple:
- share one primitive and theme foundation
- keep separate authoring contracts for persistent app UI, workflow UI, transition UI, and bounded custom UI
Mozaiks should not flatten those surfaces into one generic AG-UI-style contract.
Canonical surfaces¶
Finite surface IDs are defined in mozaiksai/core/workflow/ui_surface_taxonomy.py and injected into generator prompts. Agents must emit only these IDs:
declarative_pagecustom_react_pageagent_tooltransition
| Surface | Owner | Contract | Runtime path |
|---|---|---|---|
| Persistent App UI | AppGenerator | app.json + ui/pages/*.yaml | SchemaPage -> PageRenderer -> page primitives |
| Workflow UI | AgentGenerator / handwritten workflows | Python tool + shipped shared component or workflow-local React | use_ui_tool(...) -> chat.tool_call -> WorkflowUIRouter |
| Transition UI | workflow pack author / shell author | extension_registry.json + transition component | RouteRenderer / TransitionScreen |
| Bounded custom UI | AppGenerator custom route bundle, module/admin JS stubs | ui/route_manifest.json, ui/pages/custom/*.jsx, explicit stub contracts | route/component registry |
Design-to-plan rendering boundary¶
Every DesignDocs ExperienceSpec.pages[] entry explicitly declares ui_surface: declarative_page or custom_react_page. AppBuildPlan preserves that decision for the approved route. Plan review rejects a changed rendering contract before tasks run, with the required path and repair guidance. Prompt path guidance uses the same planned_page_path materializer as validation and task ownership.
A custom page may have empty sections or valid primitive composition hints. Its intent retains the required interaction; fake primitive config cannot stand in for state and event handlers. One page task owns the custom JSX files, ui/route_manifest.json, and ui/index.js together. Local interaction may belong to a ui_only surface; persisted changes and reads remain bound to approved module actions and ownership policies.
App-owned domain surfaces must use identifiers outside the platform ownership catalog's reserved names. For example, focus-session statistics cannot claim the platform authentication identifier user_sessions. Design validation returns rename guidance: update the surface ID, collection ownership and matching page bindings together while preserving domain data, actions and pages. It does not silently move those records into another module or weaken identity ownership.
This is a pre-production contract replacement. A saved ExperienceSpec without ui_surface must re-enter DesignDocs through the existing design/build journey before app planning. There is no renderer guess or promotion compatibility path. Recorded test fixtures explicitly label their existing declarative realization; that migration is not evidence that the original model emitted the new field.
Shared primitive foundation¶
These surfaces should share one design foundation, not one authoring contract.
Shared foundations:
- semantic tokens from
brand/theme_config.json - shared shell chrome from
config/shell.json - shared media inventory from
config/asset_manifest.json - shipped page primitives from
chat-ui/src/ui/page-renderer/PrimitiveRegistry.js - shipped workflow/component primitives from
chat-ui/src/ui/primitives/index.js - generator-side primitive guidance from
mozaiksai/core/workflow/ui_primitives.py
Use the same primitives where possible. Do not collapse the producers that use them.
Form defaults and table search¶
Form.config.fields[].default_value declares a literal scalar default: string, number, boolean, or null. For example, a status select can declare default_value: planned with a matching select option. Objects, arrays, expressions, and top-level form defaults are not supported. In edit forms, initial_values_key: selected_row supplies existing non-null field values ahead of defaults, preserving false, 0, and empty strings. An omitted or null default falls back to the primitive's empty value (false for checkboxes, an empty string otherwise).
Both DataTable and ResourceTable accept config.search_keys, an optional list of row keys. With search: true, search_keys: [title, author] searches only those keys, even when notes are also displayed. Omitted or null keys search all declared column keys; an empty list searches no fields. Keys may refer to non-displayed fields present in the loaded rows. These are client-mode search semantics. Server paging delegates search to the module as described below.
These fields pass through the strict runtime page model, AppGenerator structured output, and save_app_schema YAML materialization. SectionRenderer forwards them to the existing primitives. Generator prompt guidance comes from the shared primitive catalog; regenerate it with node scripts/export-primitive-schemas.js after changing PrimitiveSchemas.js.
Server-paged lists¶
DataTable.config.pagination_mode is client (default) or server. For both DataTable and ResourceTable, structured outputs default omitted input to client; explicit null is invalid. Provider-facing strict output schemas require the mode explicitly. Ordinary JSON dumps must retain a runtime-valid mode without relying on null omission or normalization. Use server mode whenever the list endpoint returns a bounded page, including ordinary CRUD lists. Client mode retains local filtering, sorting, and slicing of fully loaded rows; it sends no paging query. Never fetch an unbounded dataset to make client pagination appear complete.
Server mode requires pagination: true, a canonical module api_endpoint, an explicit positive integer page_size from 1 through 100, and explicit response paths data_key and total_key. Paths are dot-separated object keys, not expressions. total_key is not valid in client mode. ResourceTable supports client mode only: its resource filters/sorts are not a server query contract, and server mode is rejected before materialization or fetch. Use DataTable for server-paged lists.
primitive: DataTable
config:
columns: [{key: title}, {key: author}]
api_endpoint: /api/modules/books/list_books
data_key: items
total_key: total
pagination: true
pagination_mode: server
page_size: 20
search: true
The existing module GET route receives exactly page (1-based integer), page_size (integer), and search (literal string, including an empty string). The action must declare these inputs in its module schema, validate their bounds, and return rows plus a nonnegative integer total for the same authorized filter before paging. The GET boundary decodes only schema-declared integer strings matching -?(0|[1-9][0-9]*), at most 16 characters including any minus sign. Invalid or longer values remain strings and fail the existing integer schema validation; this transport bound is not a new limit on JSON integers. It does not coerce search text, accept query aliases, or bypass module authorization/input validation. POST input typing is unchanged: numeric strings are not decoded and JSON integers remain subject to the declared schema bounds.
The module owns tenant/user policy, allowed search fields, literal matching, and stable ordering with a unique tie-breaker. For Mongo-backed modules, use the existing ctx.persistence collection aggregate() with the module policy filter, fixed $sort, $skip: (page - 1) * page_size, and bounded $limit. Compute the total with the same policy/search filter. Escape literal text when using a regex predicate. Do not accept caller-supplied pipeline stages or field selectors. find_many() has no offset; repeated first-page results are not pagination. No additional persistence adapter is needed.
usePageData owns per-section page/search state and abortable requests. PageRenderer and SectionRenderer pass that state into DataTable. Server-mode DataTable never filters, sorts, or slices the returned page; interactive column sorting is disabled. search_keys is client-only and is not a backend search policy. Page changes clear selection; search changes immediately update the input, invalidate rows/selection, and return to page 1. Only changed server searches wait for a 250ms quiet interval before fetching; the request controller cancels superseded search timers as well as in-flight requests. Initial loads, page changes, and explicit refetches have no debounce delay. Refetch after mutations preserves the query. If deletion invalidates the final page, the hook clamps to the last valid page and fetches once again, never relabeling stale rows. Further inconsistent results fail with retryable error state. Missing/invalid totals, non-object rows, and page lengths inconsistent with the requested page and total fail closed, not as a successful empty list. Superseded/unmounted requests cannot publish success, errors, or loading state.
Brand-driven visual identity¶
app/brand/theme_config.json is the canonical visual identity source for an app workspace. Runtime serves it through /api/theme-config; the frontend theme loader turns it into semantic CSS tokens before generated pages render.
app/config/shell.json owns shell behavior: navigation policy, chrome modes, header/profile/footer behavior, and compact shortcuts. It must not carry raw visual token values such as colors, font stacks, spacing scales, or page-local brand palettes.
Generated React and custom routes must use:
- shared primitives
- semantic primitive variants such as
Button variant,StatusPill tone, card or panel variants, density, radius, and status tone - semantic classes and tokens such as
bg-background,bg-card,text-foreground,text-muted-foreground,text-primary,border-border,font-sans, andfont-heading
Generated React and custom routes must not hardcode:
- hex, RGB, or HSL color literals
- literal font-family names or
font-familydeclarations - page-local brand palettes
- one-off local button, card, badge, or status styles when a shared primitive exists
- repeated local rounded card shells when
SurfaceCardorPanelalready fits
Brand values flow through the compact selector layer:
theme.primarytheme.radiustheme.fonttheme.font_headingtheme.appearancetheme.density
They may also flow through the expanded runtime shell token layer:
fontscolorsshadowsuiprimitives
theme.* is the compact selector layer for App UI --mz-* tokens. fonts, colors, shadows, ui, and primitives are the expanded shell/chat token layer for --color-*, --font-*, and --core-primitive-*. Both surfaces are part of the current theme contract until all shell tokens move to --mz-*.
Local fonts live only under app/brand/fonts/ and are referenced from theme config with /fonts/... URLs. Do not copy font binaries into generated artifacts outside brand/. Google Fonts are declared in theme_config.json and injected by the theme loader.
Custom route primitive contract¶
Bounded custom UI is still part of the shared design system. A custom React route may exist because the page needs behavior that YAML cannot express, but it must not create a parallel visual system.
Custom route files under ui/pages/custom/*.jsx should import shared primitives from @mozaiks/chat-ui/ui for the common UI shapes:
Button,ActionButton,IconButton,LinkButtonfor actionsStatusPill,Alert,AlertBannerfor state and noticesSurfaceCard,Panelfor cards and panelsMetric,SummaryStrip,SegmentedBarfor compact summariesCollectionToolbar,ResourceList,ResourceTable,DataTablefor search, lists, and tabular recordsInlineEmptyState,LoadingState,ErrorState,Skeletonfor feedback
Thin domain wrappers are allowed when they delegate to shared primitives. For example, a notifications page can implement NotificationRow as a wrapper around ResourceList cells and StatusPill, or a managed analytics dashboard can implement AnalyticsMetricGroup as a wrapper around SummaryStrip.
Local visual primitive clones are not allowed:
function StatusPill(...)function MetricTile(...)function StatCard(...)function Badge(...)- raw primary buttons such as
<button className="...bg-primary..."> - repeated local rounded card shells when
SurfaceCard,Panel,ResourceList, orSummaryStripalready fits
Quality-gate false-positive boundary:
- generated React audits intentionally ignore files under
docs/**andtests/**fixture paths, even when they contain JSX snippets, so examples and test fixtures do not block real generated app output.
Visual values come from the active app brand, not from custom-page constants. Generated apps should treat app/brand/theme_config.json and the shell/theme semantic token layer as the authority for colors, radius, spacing, density, and typography. Custom React may use semantic classes such as bg-card, text-primary, border-border, text-success, and text-muted-foreground; it must not hardcode hex colors, brand color names, or local font choices.
Custom route ownership has three required pieces and they must stay in sync:
ui/route_manifest.jsondeclares the route path and component keyui/pages/custom/*.jsxprovides the full-page React componentui/index.jsregisters the same component key withregisterComponent
Scoped private routes may declare meta.routeAuth. Declarative pages put it in ui/pages/*.yaml; custom routes put it in ui/route_manifest.json pages[].meta.routeAuth. Use it when a path or query parameter identifies a resource and access depends on app-owned membership, ownership, tenant, workspace, or invitation state. The shell resolves placeholders such as $route.projectId, $query.team, $user.id, and $path, calls the declared app module action, and renders only when the response contains allowed: true. This is a pre-render/deep-link gate; the target module action and all backend module actions remain responsible for authoritative authorization.
admin/admin_registry.yaml is not a route registry. It declares admin page ids, paths, scope, ordering, and labels for the admin shell. Full-page custom React routes must use the route manifest and component registry contract above. Module admin panels reference admin page ids from their module-owned contracts/admin.yaml; they do not add route component keys to admin_registry.yaml.
Persistent app UI¶
Persistent app UI owns:
- durable routes
- dashboards
- lists
- detail pages
- forms
- boards
- product workspaces
Rules:
- default to declarative page schemas
- prefer primitive composition over raw React
- use
custom_route_bundleonly when the shipped page primitive system cannot express a durable route cleanly - durable state changes should go through module actions and API surfaces
Persistent pages may launch workflows, but they do not render workflow-local React directly.
Canonical workflow launch seam from a page:
actions:
- id: review-with-ai
label: Review With AI
action_type: workflow
workflow_id: CustomerSupport
context_variables:
customer_id: "{id}"
source_page: customer-detail
Rules:
workflow_idis a runtime workflow registry id, not a workflow capability idcontext_variablesmust stay deterministic and match the target workflow context contract- do not fake workflow launch with
navigateorui.*events
App UI Quality Gate¶
Persistent AppGenerator pages have a deterministic gate before assembly:
AppSchemaAgentemitsAppSchemaOutputsave_app_schemapersistsapp.json,ui/pages/*.yaml, optional custom route artifacts, andapp_ui_quality_warningsfactory_app/workflows/_shared/generated_ui_contract.pyaudits both declarative page schemas and optionalcustom_route_bundle.page_files- the generated React portion of that audit excludes
docs/**andtests/**fixture paths to reduce false positives in documentation and test examples AppUIQualityAgentcallsreview_ui_qualityapp_ui_quality_status == "passed"is required beforeAssemblyAgentneeds_revisionroutes back toAppSchemaAgent;blockedroutes to user/operator review
This is the production bar for schema-driven UI. The gate is runtime state, not prompt prose, so live AG2 runs cannot assemble generated app UI until the quality status passes.
After the gate passes, assembly must collect files from the persisted generated_app_dir app workspace. The schema-driven path does not rely on the LLM to reconstruct code_files from chat history. Downstream assembly, validation, and download tools read the canonical saved artifacts:
app.jsonui/pages/*.yaml- optional
ui/route_manifest.json - optional
ui/pages/custom/* - optional
ui/index.js - optional
brand/theme_config.json - optional
config/*.json
The first browser-level acceptance target for this lane is:
That Playwright suite serves a generated app fixture as the active app root, loads canonical ui/pages/*.yaml through SchemaPage, and verifies primitive rendering across desktop and mobile. It complements the AG2 quality gate: the gate decides whether agents may assemble artifacts, while Playwright proves that the approved artifacts render cleanly in the shell.
Playwright is intentionally a second-stage render acceptance check, not the same thing as the AG2 quality gate. The AG2 gate is cheap, deterministic, and agent-readable before assembly. Playwright runs against rendered browser output after artifacts exist and catches issues the schema audit cannot see: missing routes, browser errors, invisible headings, horizontal overflow, broken declarative form submission, and responsive layout regressions.
Browser findings flow through scripts/generated_ui_acceptance.py when they need to become structured production-readiness feedback:
- canonical
ValidationRunwith thegenerated_ui_browsergate result - structured
ValidationIssueentries with code, severity, route, message, suggested fix, and repair owner - one
RepairDecision:accept,repair, orblock
This keeps strictness bounded. Playwright failures should be converted into specific canonical issues; they should not become an open-ended agent loop. The shared acceptance controller owns the repair budget and blocks to user/operator review when the evidence is unchanged or the budget is exhausted. The script does not maintain a second browser-specific retry state machine.
To run the generic rendered-app smoke against a generated app root instead of the checked-in fixture:
$env:MOZAIKS_GENERATED_UI_APP_ROOT="generated/apps/{app_id}/{build_id}/app"
npm --prefix web_shell run test:generated-ui:app
For structured production-readiness output:
python scripts/generated_ui_acceptance.py `
--app-root "generated/apps/{app_id}/{build_id}/app" `
--output generated-ui-acceptance.json
Workflow UI¶
Workflow UI owns:
- human approval cards
- agent-driven forms
- artifact panels
- diagrams
- transient review and planning surfaces
- response-required checkpoint UI
Workflow-local React now has a deterministic gate:
UIFileGeneratorpersists its emitted UI files throughsave_workflow_ui_files_outputfactory_app/workflows/_shared/generated_ui_contract.pyaudits generated workflow-local React with the same primitive/copy/style rules used by AppGenerator custom ReactWorkflowUIQualityAgentcallsreview_workflow_ui_qualityworkflow_ui_quality_status == "passed"is required before backend tool generation and bundle deliveryneeds_revisionroutes back toUIFileGenerator;blockedroutes to user/operator review
Rules:
- response-bearing workflow UI uses
use_ui_tool(...) - fire-and-forget workflow UI uses
emit_ui_surface(...) - wire contract is
chat.tool_call - response contract is
tool_call_response - generic text checkpoints in
chat-uishould use the standard composer lane (interaction_type=input_request,display=composer) - inline React components are for structured workflow interaction, not default free-text reply
- framework-owned shared workflow primitives live in
chat-ui/src/core/ui/and are mounted byWorkflowUIRouter - factory-owned reusable workflow components live under
factory_app/workflows/_shared/ui/and must be imported/re-exported by each consuming workflow'sui/index.js; they are not auto-registered - runtime-enriched workflow payloads should carry manifest-owned
workflow_primitive,ui_realization, andui_contract - shipped shared workflow components should derive their actions from
ui_contract.actions_schema - workflow-local React is only for genuine customization or primitive gaps
- workflow-local React must import shared primitives from
@mozaiks/chat-ui/ui; deepchat-ui/srcimports and removed primitives (Card,Stat,Badge) fail the gate UIFileGeneratorshould emit canonicalCodeFileentries under the workflow'stools/andui/tree, not ad hoc frontend payload shapes- workflow assembly synthesizes
ui/index.jsdeterministically from the workflow-local component files that remain after contract validation - workflow interaction planning should use the canonical Workflow UI Primitive Catalog
- shell-owned workflow status surfaces such as progress, run status, and agent activity are not workflow-local React generation targets
- every real workflow manifest UI entry must declare
ui.workflow_primitive - every real workflow manifest UI entry must declare
ui.realization composer_replyremains shell-owned and must not generate aUI_ToolorUI_Surfacemanifest entry- shipped workflow primitives should prefer the canonical shared component names directly
ui.realization=shipped_componentmeans no workflow-local React file should exist for that checkpointui.realization=workflow_wrappermeans the workflow owns only a thin wrapper/re-export around a shipped primitiveui.realization=generated_componentmeans the workflow owns the full custom React surface- workflow-local wrappers around shipped components should stay thin and intentional
- if
ui.componentalready equals the canonical shipped component name, no workflow-local React file should be generated or saved for that checkpoint - the first deterministic smoke target for this contract is described in the live smoke workflows alongside
RuntimeSmokeandRuntimeToolCallSmoke - the stable real-AG2 regression target is
factory_app/workflows/AgentGeneratorwith the workflow-owned smoke pair:factory_app/workflows/AgentGenerator/smoke_prompt.txtandfactory_app/workflows/AgentGenerator/smoke_responses.json
This is the surface that should keep borrowing ideas from AG-UI and CopilotKit:
- explicit tool lifecycle
- clearer interrupt/resume semantics
- simpler renderer ergonomics
It is not the contract for persistent app pages.
Transition UI¶
Transition UI owns:
- pre-workflow routing
- between-workflow checkpoints
- deterministic user choices
- seeded launch context
Rules:
- author routing in
extension_registry.json - keep workflow sequences runtime-oriented
- keep branded visuals in transition React components, not in routing metadata
Bounded custom UI¶
Use bounded custom UI only where a strict contract already declares it:
- app-level custom durable routes via
custom_route_bundle - admin/module custom components via explicit JS stub contracts
- workflow-local React through workflow UI contracts
Do not create a second freeform page-generation lane.
Workflow integration rules¶
There are two different identifiers in play. Keep them separate.
Direct page launch:
- uses workflow registry id
- example:
CustomerSupport
Event-driven workflow routing:
- uses workflow capability id
- example:
customer-support
Rules:
- page actions use workflow registry ids
event_flowsand runtime trigger/reaction contracts use workflow capability ids- startup mode shapes user expectations only; the runtime decides who speaks first after session launch
What to borrow from AG-UI and CopilotKit¶
Borrow:
- cleaner workflow UI protocol semantics
- explicit tool lifecycle state
- explicit interrupt/resume modeling
- shared-state discipline when a real protocol-level state stream is needed
Do not borrow:
- the idea that every interactive frontend surface should become a tool renderer
- the collapse of persistent app pages into chat-only UI
Production direction¶
The production-ready target is:
- one primitive/design foundation
- one canonical workflow UI transport lane
- one canonical persistent page schema lane
- one explicit page-to-workflow launch seam
- no overlap between page primitives and workflow-local React ownership
That gives Mozaiks the protocol discipline of AG-UI where it matters, while preserving the broader generated-app architecture that CopilotKit alone does not cover.