Browse documentation
Docs/Build

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.

Watch it in Grid

See the concept in Grid.

Use the film for the product interaction and this article for the complete contract and reference detail.

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, and SelectedAccount are writable interaction state.
  • Accounts is a read-only row source.
  • TotalPipeline is a derived metric.
  • StatusMessage is 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:

  1. Give it a unique title.
  2. Give it a unique package-relative path such as dist/dashboard or dist/accounts.
  3. Mark one page as Home.
  4. Put a Navigation block on pages that need visible navigation.
  5. 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, with name as the select-value column
  • Alert message → StatusMessage, with visibleWhen = 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:

  1. The model declares the binding as an input or otherwise gives it writable ownership.
  2. The App block uses a write or readwrite slot, so the builder declares the symbol.
  3. 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:

  • @item for the whole row;
  • @item.name for one field;
  • @item.#index for the one-based row number;
  • @item.#count for 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:

  1. Deploy the model; publishing is unavailable without a deployed model ID.
  2. Resolve required binding errors and confirm input ownership.
  3. Check all page paths and choose the home page.
  4. Inspect the App framework readiness, actions, Reads, Writes, and capability review.
  5. Preview Desktop, Tablet, and Mobile layouts.
  6. 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.js and dist/grid-vue.js, framework adapters;
  • dist/grid-connector.js, the shared grid:model-ui:v1 connector 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.json declares pages, symbols, capabilities, actions, and framework entry files.
  • custom-ui/README.md describes the replacement flow.
  • custom-ui/main.jsx starts a React UI over useGridApp.
  • custom-ui/vue-main.js starts a Vue UI over the generated composable.
  • custom-ui/grid-app.d.ts re-exports the app-specific types.
  • custom-ui/index.html is 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:

  1. Start from custom-ui/README.md and the framework starter.
  2. Preserve the generated read/write symbols and capability boundaries.
  3. Build and test navigation, empty/loading/error states, and write rejection.
  4. Point ui/manifest.json.entry from dist/index.html to custom-ui/index.html, or replace the JavaScript behind the existing entry.
  5. 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.