Build Grid apps
Turn a reactive model into a routed, model-bound application with App Builder, then preview, review, publish, or hand it off to custom UI.
Build Grid apps
Grid App Builder turns a reactive model into a multi-page application without moving state or business logic into a separate front end. Pages provide routes, blocks provide presentation and interaction, bindings connect those blocks to the model, and publishing makes the generated package the model's App Home.
Want to build one before reading the complete contract? Follow Build your first Grid app. It covers model → pages → bindings → actions → preview → publish in one guided build.
Use the App pattern gallery when you want a working records, operations, scenario, approval, or connector-backed starting point. Use the generated App block and action reference when you need every prop, binding, action, condition, and capability rule.
This authoring guide and its 25 tutorial Grid artifacts have been compiled against the frozen Grid 0.65.0 candidate source. The canonical product contract remains Workbook Surfaces.
Understand the four application concepts
These names describe different parts of the same system:
| Concept | What it means |
|---|---|
| Surface | A workbook tab that presents model state as a table, map, layout, App, Component, or another purpose-built view. |
| App surface | The structured, visual builder. Its safe interpreter renders a known JSON block tree without evaluating authored code. |
| App Mode | The end-user runtime shell. It opens a promoted surface or the model's published UI package without the full workbench. |
| Component surface | Authored JSX that runs in-process and requires a trusted deployment posture. |
An App surface is not itself App Mode, and an app-like Table or Layout is not an App surface. A model can use any of these paths as its runtime entry.
Choose the shortest authoring path
| Need | Start with | Move on when |
|---|---|---|
| Records, a board, map, calendar, chart, document, or dashboard | The matching built-in surface | The workflow needs a custom screen or routed flow. |
| A form, records workflow, dashboard, or routed app using standard blocks | App | A required interaction cannot be expressed by blocks, bindings, conditions, or actions. |
| React-level layout and behavior inside the workbook | Component | The UI should become a portable model-owned package or use another framework. |
| A hand-authored React, Vue, or plain-JavaScript application | Publish an App, then use its generated custom-ui/ handoff |
The custom project has replaced the generated entry and passed the same capability review. |
Use the least custom path that expresses the workflow. Built-in surfaces and App Builder keep authoring inspectable and work under stricter trust policies than in-process JSX.
1. Prepare app-shaped model state
The model remains the application's database, state machine, and calculation graph. Mark end-user state as writable; keep derived values as outputs or ordinary formulas.
MODEL "Pipeline App"
input Scenario = "base"
input SearchAccounts = ""
input SelectedAccount = ""
output Accounts = [
{ name: "Acme", stage: "lead", value: 42000 },
{ name: "Northstar", stage: "qualified", value: 88000 },
{ name: "River Co", stage: "proposal", value: 56000 }
]
output TotalPipeline IS currency = SUM(MAP(Accounts, account => account.value))
output StatusMessage = Scenario == "bull" THEN "Upside scenario active" ELSE ""
END MODEL
This gives an App four distinct state roles:
Scenario,SearchAccounts, andSelectedAccountare writable interaction state.Accountsis a read-only row source.TotalPipelineis a derived metric.StatusMessageis model-calculated feedback for an Alert.
If an action must append to Accounts, make the row source explicitly writable instead of assuming that a UI allowlist changes formula ownership. See Assignments and ownership for the language contract.
2. Create the shell and pages
Create an App surface from the workbook create menu. A blank App starts with one Home page and a Stack containing a heading and introductory text.
For a full application skeleton, use Shell in the Pages panel or choose an inferred App pattern. The shell starter uses the model's live symbols to seed Dashboard, Records, and Inputs pages, Navigation blocks, responsive sections, and suitable record or form blocks. Treat generated blocks as a first draft: remove irrelevant bindings and check every write.
In the Grid 0.65.0 candidate contract, Shell is a one-shot inference over the live symbols already resident in App Builder;
it does not parse source declarations. Before choosing Shell, switch to Default and wait until the
named values and row sources resolve, return to the App, and verify that From model lists them.
Saving or reopening persists the App but does not hydrate an empty symbol snapshot by itself. If
Shell produces only Home plus Dashboard with 0 reads / 0 writes, return to Default until named
values resolve, return to the App, remove the unbound generated Dashboard, and invoke Shell once. Generated controls
know value shape, not input ownership, so every inferred write still needs an ownership check.
For each page:
- Give it a unique title.
- Give it a unique package-relative path such as
dist/dashboardordist/accounts. - Mark one page as Home.
- Put a Navigation block on pages that need visible navigation.
- Use Section, Responsive grid, or Sidebar layout for the main page regions.
Paths are normalized without leading or trailing slashes. Route buttons and published deep links use the same paths, so keep them stable after sharing an app.
3. Add blocks and bind them to the model
Drag blocks from the palette, or select a container and add a block to it. The current block families are:
| Family | Blocks |
|---|---|
| Layout | Stack, Row, Card, Form, Section, Responsive grid, Sidebar layout, Modal, Drawer, Tabs, Accordion, Tooltip |
| Display | Progress, Avatar, Image, Heading, Text, Metric, Stats, Stat, Divider, Badge, Alert |
| Navigation | Link, Menu, Steps, Navigation |
| Input and actions | Number input, Text input, Text area, Date input, File input, Toggle, Slider, Select, Segmented control, Button, Submit button |
| Data | Table, Detail, Chart, List, Repeater |
Select a block and use its binding picker. Required slots must be bound before the block is complete; the inspector reports missing bindings and obvious type mismatches.
The exhaustive, source-generated catalog is App block and action reference.
A useful first page for the example model is:
- Metric
value→TotalPipeline - Select
value→Scenario - Text input
value→SearchAccounts - Table
rows→Accounts - Table
filter→SearchAccounts - Table
selected→SelectedAccount, withnameas the select-value column - Alert
message→StatusMessage, withvisibleWhen = StatusMessage is set
The Table and input now share model state. Search and selection are not hidden React state, so another surface or formula can observe them.
App document and block schema
Authors normally edit an App through the builder. Tools and agents can rely on the persisted document contract below.
An App surface uses the standard surface slots:
| Slot | App usage |
|---|---|
!type |
The literal surface family, "app". |
!config |
TOML metadata plus the derived read/write binding contract. |
!data |
The serialized App document: pages, theme, blocks, and reusable components. |
!source |
Not used by App Builder. It appears on the Component created by Eject to Component. |
The configuration shape is:
PipelineApp!type = "app"
PipelineApp!config = """
version = 1
kind = "app"
title = "Pipeline App"
[bindings]
reads = ["Accounts", "Scenario", "SearchAccounts", "SelectedAccount", "StatusMessage", "TotalPipeline"]
writes = ["Scenario", "SearchAccounts", "SelectedAccount"]
"""
The builder derives those arrays from the block tree and keeps !config synchronized. Do not maintain a second hand-written allowlist that disagrees with the tree.
The JSON document has this logical shape:
interface AppDocument {
version: number;
root: AppNode; // mirrors the active page for older consumers
pages?: AppPage[];
currentPageId?: string;
homePageId?: string;
theme?: AppTheme;
componentLibrary?: AppComponentDefinition[];
shellPanels?: AppShellPanel[];
}
interface AppPage {
id: string;
title: string;
path: string; // for example, "dist/accounts"
root: AppNode;
}
interface AppNode {
id: string;
type: AppNodeType;
props: Record<string, string | number | boolean | string[]>;
bindings: Record<string, string>;
children?: AppNode[]; // container blocks only
}
Each block type owns a catalog of editable props and binding slots. A slot has one direction:
| Direction | Contract produced by the builder |
|---|---|
read |
Adds the bound symbol to bindings.reads. |
write |
Adds the target to bindings.writes. |
readwrite |
Adds the same symbol to both arrays. Inputs and selected-row state use this. |
range |
Reads a row or range source and adds it to bindings.reads. |
Conditions also add their symbols to reads. Submit writes add their targets to writes. Row-scope references do not become global model bindings because they resolve against the current Repeater row.
Model ownership is stricter than an App allowlist
bindings.writes says what the UI is allowed to request. It does not make a formula-owned symbol writable.
Before binding an input or action target, verify all three layers:
- The model declares the binding as an
inputor otherwise gives it writable ownership. - The App block uses a
writeorreadwriteslot, so the builder declares the symbol. - The published package requests
model.write, and the host grants it.
Keep calculations, validation, and workflow decisions in formulas. Bind Metrics, Text, Badges, and Alerts to those results. This lets sheets, built-in surfaces, App Builder, and custom UI share one result rather than reimplementing it.
Actions, routes, conditions, and row scope
Button actions
A Button can set, increment, decrement, write a formula, append a row, validate, route, run a job, or call a connector.
| Action | Authoring contract |
|---|---|
set, increment, decrement |
Bind a writable target and provide the value or amount. |
formula |
Bind a writable target and provide a formula. Prefer a durable model formula when other surfaces need the result. |
appendRow |
Bind a writable row target and provide field = value entries. |
validate |
Provide a declarative condition, then optionally route or write feedback after success. |
route |
Choose a page path. Optionally bind a target and provide Write before route. |
runJob |
Name a job. The package requests the job.run capability. |
callConnector |
Name a connector and optional JSON payload. The package requests connector.call. |
Routes do not require a model write. A route that also writes selected state is more powerful and receives a higher risk rating in capability review.
Submit actions
A Submit button writes several targets as one action and can route afterward. Its Writes lines use these forms:
Scenario = bull
SelectedAccount = @account.name
ApprovedAmount = @account.value
AuditStamp = =NOW()
SearchAccounts = @current
@current reads the target's current model value. A leading second = marks a formula write. Add a feedback binding when the model should display a save or approval result in an Alert.
Repeater row scope
A Repeater gives each rendered row an alias, item by default. Descendants can use:
@itemfor the whole row;@item.namefor one field;@item.#indexfor the one-based row number;@item.#countfor the total number of rows.
If the alias is account, a route button can write @account.name to SelectedAccount and then navigate to dist/account. The detail page reads SelectedAccount, so selection remains explicit model state.
Declarative conditions
Every block supports visibleWhen and disabledWhen. Button validation uses the same safe comparison grammar:
Approved is true
Status is set
Status is empty
Amount > 0
Scenario == bull
@account.stage != closed
Supported binary operators are ==, !=, >, >=, <, and <=; supported keyword forms are is true, is false, is empty, and is set. Conditions are comparisons, not executable code. An unparseable condition is treated as no condition, so check the resulting state in Preview.
Preview the states that matter
Use Preview to leave tree editing and inspect the app at Desktop, Tablet, and Mobile widths. Use Edit to return to the builder. Preview is live, not read-only: model-bound inputs and actions can write, run jobs, or call connectors according to their capabilities. Use disposable data for destructive checks, then validate the same contract in Open as app or the published App Mode.
App data blocks render binding state directly:
| State to verify | What to look for |
|---|---|
| Bound | Live scalar values, rows, selection, filters, conditions, and local page routing use the current model snapshot. |
| Unbound | Required slots show a binding error or an instruction such as “Bind a range to show rows.” |
| Empty | Tables, Lists, Repeaters, Details, and Charts render their authored empty message. |
| Loading | Row blocks render a loading hint while their source resolves. |
| Error | Row blocks show the source error; scalar blocks report incompatible value shapes near the control. |
| Responsive | Grid and Sidebar regions collapse at their configured breakpoints; breakpoint visibility rules hide the intended blocks. |
App Builder has no separate Mock/Empty/Loading/Error selector. Those states come from the current binding snapshot. Exercise them with a test model or controlled connector state, then restore normal data before publishing.
Review trust and capabilities
Custom UI availability is a deployment policy:
VITE_GRID_CUSTOM_UI_TRUST |
App Builder | Packaged App Mode | Component / ejected JSX |
|---|---|---|---|
off |
Disabled | Disabled | Disabled |
sandboxed-only |
Enabled, using the no-eval interpreter | Enabled in a sandboxed frame | Disabled |
trusted |
Enabled | Enabled | Enabled in-process |
When the unified setting is absent, legacy App and Component feature flags preserve their existing behavior. Public embeds never evaluate in-process JSX.
The App framework panel derives a capability review from the document:
| Scope | Added when |
|---|---|
model.read |
At least one model symbol is read or written. |
model.write |
At least one model symbol is written. |
app.route |
A button or submit action changes route. |
job.run |
A button runs a job. |
connector.call |
A button calls a connector. |
Review the exact symbols under Reads and Writes, every Action builder plan, and the overall risk before publish. The host enforces write symbols separately from the coarse capability: a package with model.write still cannot write a symbol outside its manifest allowlist. A host without a requested job runner or connector bridge returns method_unavailable.
Publish the app and enter App Mode
There are three related runtime actions:
| Action | Result |
|---|---|
| Open as app | Opens the selected surface in the App Mode run shell without changing the model's default entry. |
| Make entry | Makes that surface the model's promoted runtime entry. |
| Publish Grid App | Generates and stores a model-owned UI package, then makes it the model's App Home. |
Before Publish Grid App:
- Deploy the model; publishing is unavailable without a deployed model ID.
- Resolve required binding errors and confirm input ownership.
- Check all page paths and choose the home page.
- Inspect the App framework readiness, actions, Reads, Writes, and capability review.
- Preview Desktop, Tablet, and Mobile layouts.
- Exercise writes and command integrations in a safe run environment.
Publishing writes a grid.model-ui.v1 package with dist/index.html as its generated entry, the selected home route, requested capabilities, read/write symbols, theme, and frameless shell metadata. It also includes:
dist/app-meta.json, the machine-readable app/framework inspection record;dist/GRID_APP.md, the human-readable model contract and handoff guide;dist/grid-app.js, typed symbol guards and action helpers;dist/grid-react.jsanddist/grid-vue.js, framework adapters;dist/grid-connector.js, the sharedgrid:model-ui:v1connector and state helpers;- generated JavaScript, styles, TypeScript declarations, and starter files;
custom-ui/, a replacement project described below.
This publish action stores the UI package with the model. It is not a deployment to grids365.dev, a grid ns release, or an access-policy change.
When a model has both a published package and a promoted surface, the package is the default App Home. An explicit Surface URL still opens that surface. Inside App Mode, Dev mode returns to the workbench; Shift+Esc, Cmd+Esc, or Ctrl+Esc is the keyboard escape path.
Hand off to custom UI
Choose one of two code paths.
Eject inside the workbook
Eject to Component creates a new Component surface with generated JSX and a matching narrow binding config. Ejection is one-way: the original App document remains the visual source, while the Component becomes independently authored code. This path requires trusted custom UI for execution.
Use it when the UI should remain a workbook surface and React-level control is the only missing piece.
Replace the published renderer
Every published App package includes a custom-ui/ project:
custom-ui/manifest.jsondeclares pages, symbols, capabilities, actions, and framework entry files.custom-ui/README.mddescribes the replacement flow.custom-ui/main.jsxstarts a React UI overuseGridApp.custom-ui/vue-main.jsstarts a Vue UI over the generated composable.custom-ui/grid-app.d.tsre-exports the app-specific types.custom-ui/index.htmlis the replacement entry path.
Framework-neutral code can use createGridAppState; React can use useGridApp from grid-react.js; Vue can use useGridApp from grid-vue.js. These adapters share the same connector and model contract as the generated App renderer.
For a complete contract-preserving handoff, follow Take an App Builder contract to React or Vue.
To replace the generated entry:
- Start from
custom-ui/README.mdand the framework starter. - Preserve the generated read/write symbols and capability boundaries.
- Build and test navigation, empty/loading/error states, and write rejection.
- Point
ui/manifest.json.entryfromdist/index.htmltocustom-ui/index.html, or replace the JavaScript behind the existing entry. - Repeat capability review whenever custom code adds a symbol, job, connector, or route action.
Troubleshoot authoring and launch
| Symptom | Check |
|---|---|
| “App builder surfaces are disabled” | The deployment must allow App Builder through VITE_GRID_CUSTOM_UI_TRUST or its legacy App flag. |
| “App configuration is invalid” | Repair !config: version = 1, kind = "app", a title, and valid [bindings] arrays. |
| “App surface not bound” | Open a deployed model with live bindings; verify the referenced symbol exists and is not a surface implementation slot. |
Shell creates only Home + Dashboard and reports 0 reads / 0 writes |
The live symbol catalog was empty when Shell took its snapshot. Switch to Default until named values resolve, return to the App, confirm From model is populated, remove the unbound Dashboard, and run Shell once. |
| A block says a binding is required | Bind every required slot. Row blocks need a row/range source, not a scalar. |
| A value shows a type error | Match Number input, Toggle, text controls, Metric format, and row blocks to the model value shape. |
| “Write blocked: Symbol” | Confirm the block declares that target and the tree-derived write list includes it. Then confirm the model binding itself is writable. |
| A write allowlist exists but the value does not change | An allowlist grants no ownership. Change a formula-owned target to an input only when end users should control it. |
| A route returns to Home | Use the exact package-relative path from Pages and keep page paths unique. |
| A route works in Preview but not after publish | Republish after changing pages or paths, then test the current package revision in App Mode. |
| Publish is disabled | Deploy the model first and verify the host exposes package publishing. |
A job or connector action fails with method_unavailable |
The host does not expose that command bridge, or the package was not granted the corresponding capability. |
| Ejected JSX does not render | sandboxed-only permits App Builder and packaged apps, not in-process Component JSX. Use trusted or keep the UI packaged. |
| App Mode opens the wrong UI | A published package is the default App Home; use an explicit Surface URL for a promoted surface, or republish the intended App. |
Definition of done
An authored App is ready when:
- model inputs own every intended edit and formulas own every derived result;
- every block binding resolves with the expected value shape;
- page paths are unique, navigation works, and one page is Home;
- empty, loading, error, conditional, and responsive states are understood;
- every action has a narrow target and expected feedback;
- capability review names the exact reads, writes, routes, jobs, and connectors;
- write behavior passes in a safe run/App Mode environment;
- Publish Grid App makes the intended package the model's App Home;
- the Dev mode control and escape shortcut return to the workbench.
Next, complete Build your first Grid app, choose a working App pattern, or read the canonical App surface contract.