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 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 layer The focused runtime shell. It opens a model view or published UI package, with compact navigation and contextual assistance. Earlier releases call this App Mode.
Component surface Authored JSX that runs in-process and requires a trusted deployment posture.

An App surface is the builder and its saved content; the App layer is a way to use that content. The layer can also show a document, notebook, or another specialist surface. App Home identifies the model's runtime entry; it is not another layer.

Use Data for the builder instructions below and Code for model source, surface files, draft previews, connections, and extensions. The layer selector moves between Code, Data, App, and Chat while keeping the same model. A personal starting-layer preference can override which layer opens; choosing a model entry and choosing a personal starting layer are separate actions. See Interface layers.

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:

# Pipeline application
PipelineApp!type = "app"

PipelineApp!config = <toml>
  version = 1
  kind = "app"
  title = "Pipeline App"

  # Model values this application may read or edit.
  [bindings]
  reads = ["Accounts", "Scenario", "SearchAccounts", "SelectedAccount", "StatusMessage", "TotalPipeline"]
  writes = ["Scenario", "SearchAccounts", "SelectedAccount"]
</toml>

TOML, YAML, and JSON language blocks produce structured configuration values. Legacy quoted TOML remains supported. See the Grid style guide for spacing, indentation, and comments.

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 layer.

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 layer 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 the App layer

There are three related runtime actions:

Action Result
Open as app Opens the selected surface in the App layer 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. In Grid 0.66.8, choose Data in the layer selector to return to the builder, or Code to inspect source. Older builds expose the Dev mode return control. A model's default entry does not prevent moving between its layers.

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.

Use the App layer around your view

In Grid 0.66.8, open Code → Surfaces → App settings to shape the model's view navigation: labels, groups, order, descriptions, opening view and canvas width. Reading suits a document, Wide a notebook, and Full an application. These settings preserve surface names used by formulas and links; they do not replace the App Builder's own page routes.

App's Context panel can inspect live values and open their definitions in Code. A package generated with the current connector can contribute a symbol through await grid.setContext({ symbol: "TotalPipeline", title: "Pipeline total" }). Grid reads the value itself; the app supplies a reference and optional description, not a claimed result or a replacement host panel. This requires model.read. Pass null to clear the selection. Older generated packages may need regeneration before using this connector method; preserve authored custom files first.

The question bar opens assistance in App. Expanding it continues the same conversation in Chat, where proposed source changes remain subject to explicit review and Apply. A configured provider or compatible local model is required. Conversations, unfinished questions and supported image attachments are retained on this device, not synchronized as shared Cloud history.

Document edits can be saved to the live model while its archive still needs an update. App Context distinguishes Live from Archive: Update available; choose Save current model in Data to update that archive. Actual source drafts and save failures remain visible. See Interface layers for recovery, image limits and the current keyboard controls.

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 the App layer.
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.
The App layer 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 App-layer environment;
  • Publish Grid App makes the intended package the model's App Home;
  • the layer selector returns to Data for builder work and Code for source work (older builds use Dev mode).

Next, complete Build your first Grid app, choose a working App pattern, or read the canonical App surface contract.