What you will build
A hand-authored React or Vue approval queue that keeps the model contract generated by App Builder. The screen will subscribe to the same six model reads, write only the same four inputs, keep the same three routes, and surface connection or write failures instead of silently falling back to local state.
This is a package handoff, not a rewrite of the Grid model. Complete Build your first Grid app first, or download its Completed app and deploy it. You also need a package workspace or host workflow that can build and republish a model-owned UI package. The browser builder generates and publishes the package, but it is not a general-purpose React or Vue project editor.
The downloads for this tutorial contain:
- Contract check, a startup assertion for the approval app's reads, writes, action routes, and capabilities;
- React custom entry, a replacement for
custom-ui/main.jsx; - Vue custom entry, a replacement for
custom-ui/vue-main.js.
Choose React or Vue for the implementation. Use the contract check with either one.
1. Publish a known App Builder baseline
Open order-operations-app.grid, deploy the model, select Approval_App, and choose Publish Grid
App. Do this before customizing so the handoff starts from a package generated from the current
App document—not an older copy of its contract.
In App framework, verify the baseline:
| Contract | Expected value |
|---|---|
| Pages | Home, Requests, Review |
| Reads | Decision, Feedback, Requests, SearchRequests, SelectedRequest, TotalRequested |
| Writes | Decision, Feedback, SearchRequests, SelectedRequest |
| Capabilities | app.route, model.read, model.write |
| Home | dist/approval-queue |
Checkpoint: the package opens in App Mode, request selection survives a route change, and the approval action writes Decision and Feedback before returning to Requests.
The generated write list is an allowlist, not ownership. The four write targets also remain
declared as model input bindings. Custom code does not gain permission to mutate Requests or
TotalRequested.
2. Inspect the generated handoff
The published grid.model-ui.v1 package contains the generated renderer and a separate custom
project:
| File | Purpose |
|---|---|
dist/app-meta.json |
Machine-readable pages, actions, bindings, features, and capability review. |
dist/GRID_APP.md |
Human-readable model and package contract. |
dist/grid-app.js |
App-specific constants, typed guards, and action helpers. |
dist/grid-react.js |
React useGridApp adapter. |
dist/grid-vue.js |
Vue useGridApp composable. |
dist/grid-connector.js |
Shared grid:model-ui:v1 connector and reactive state store. |
custom-ui/manifest.json |
Handoff inventory for tools and agents. |
custom-ui/main.jsx |
Generated React starting point. |
custom-ui/vue-main.js |
Generated Vue starting point. |
custom-ui/grid-app.d.ts |
App-specific symbol and helper types. |
custom-ui/index.html |
Candidate replacement entry. It is inactive until the package entry changes. |
Keep the dist/grid-* files generated. They contain the connector handshake, subscription store,
write-symbol checks, route bridge, and Dev mode bridge. A custom screen should use those adapters
instead of opening its own transport or calling the model runtime directly.
The active package entry is still dist/index.html at this point.
3. Freeze the contract at the handoff boundary
Copy the downloaded contract-check.mjs into custom-ui/. Both sample entries call:
import { APP_CONTRACT } from "../dist/grid-app.js";
import { APPROVAL_ROUTES, assertApprovalContract } from "./contract-check.mjs";
assertApprovalContract(APP_CONTRACT);
The assertion compares binding and capability sets without depending on array order. It also freezes the home path and the four generated action plans: kinds, targets, reads, writes, success routes, feedback targets, and action capabilities. It fails when any of those change. That failure is intentional: return to App Builder, review the changed tree and model ownership, republish, and then make an explicit contract change in custom code.
Do not copy the expected binding arrays into a second runtime client. APP_CONTRACT,
APP_READ_SYMBOLS, APP_WRITE_SYMBOLS, and APP_WATCHED_SYMBOLS from dist/grid-app.js remain
the live generated authority. The guard exports APPROVAL_ROUTES so the custom renderer and its
action assertions share one route baseline. The local assertion is only a deployment guard for this
tutorial's known package.
Checkpoint: changing
Decisionto an undeclared write symbol in the check makes the custom entry fail before it can render or issue a write.
4. Implement the React entry
For React, replace the generated custom-ui/main.jsx with the downloaded
react-approval-queue.jsx. Its important seam is the generated hook:
import { APP_CONTRACT, APP_TITLE } from "../dist/grid-app.js";
import { useGridApp } from "../dist/grid-react.js";
import { APPROVAL_ROUTES } from "./contract-check.mjs";
function ApprovalQueue() {
const grid = useGridApp();
const requests = grid.rows("Requests");
const selected = grid.value("SelectedRequest", "");
async function openRequest(requestId) {
await grid.set("SelectedRequest", requestId, "string");
await grid.route(APPROVAL_ROUTES.review);
}
}
useGridApp starts and stops the subscription with the component, returns a reactive snapshot,
and routes every write through assertAppWriteSymbol. The example deliberately keeps
SearchRequests in the model rather than replacing it with component-only state. That preserves
the observable app seam used by other surfaces and formulas.
The approval path uses one batch:
await grid.setBatch([
{ symbol: "Decision", value: "approved", typeTag: "string" },
{ symbol: "Feedback", value: "Request approved.", typeTag: "string" },
]);
Keep write and route errors visible. An optimistic custom screen that reports success before the host accepts a write hides ownership and capability failures.
5. Or implement the Vue entry
For Vue, replace custom-ui/vue-main.js with the downloaded vue-approval-queue.js. Update the
replacement HTML or your build entry so it loads the Vue file instead of main.jsx.
The Vue adapter exposes the same contract through Vue reactivity:
import { computed } from "vue";
import { useGridApp } from "../dist/grid-vue.js";
import { APPROVAL_ROUTES } from "./contract-check.mjs";
setup() {
const grid = useGridApp();
const requests = computed(() => grid.rows("Requests"));
const selected = computed(() => grid.value("SelectedRequest", ""));
async function openRequest(requestId) {
await grid.set("SelectedRequest", requestId, "string");
await grid.route(APPROVAL_ROUTES.review);
}
return { requests, selected, openRequest };
}
Call useGridApp from setup() so its subscription uses Vue's mount and unmount lifecycle. Do not
create a second global connector alongside it. The sample uses computed values over the adapter's
reactive snapshot and the same batch approval operation as the React entry. Its downloadable screen
uses Vue's h() render API, so it works with the standard runtime-only Vue build and does not depend
on an in-browser template compiler.
Checkpoint: React and Vue observe and write the same symbols. Framework choice changes rendering, not the model or authorization contract.
6. Build a browser entry without breaking the package layout
The downloaded React file contains JSX and the Vue file imports Vue. They are source files, not a production browser bundle. Use your approved package build to compile the chosen entry and include its framework runtime.
Preserve these boundaries in the build:
- Keep imports from
../dist/grid-app.jsand the matching adapter resolvable at runtime, or bundle those generated modules without removing their write guards. - Emit the browser-ready custom JavaScript inside the package; do not point production HTML at uncompiled JSX.
- Keep
custom-ui/index.html's#appmount element and the generateddist/styles.css, or replace both deliberately. - Keep the package manifest's bindings and requested capabilities unchanged for this build.
- Change the package entry from
dist/index.htmltocustom-ui/index.htmlonly after the custom entry loads successfully in the package shell.
Alternatively, keep dist/index.html as the entry and replace its compiled dist/app.js. That
avoids an entry-path change but makes it especially important to keep generated and authored build
outputs separate.
Never edit only the coarse model.write capability. The package must preserve the exact write
symbol list as well; the host enforces that narrower list independently.
7. Test against the real Grid connector
A custom package should be tested inside App Mode or a host-provided package harness. Opening its
HTML directly from disk does not provide the grid:model-ui:v1 parent connector, so a failed
handshake there is expected rather than proof that the model is broken.
Use this minimum matrix:
| Case | Expected result |
|---|---|
| Connect | The initial six watched symbols resolve; the loading state clears. |
| Live update | A model-side change to Feedback appears without reloading the app. |
| Empty | A search with no matches renders “No matching requests.” |
| Error | A rejected or unavailable model operation remains visible near the action. |
| Select and route | Review writes SelectedRequest, then routes to dist/approval-queue/review. |
| Batch approval | Decision and Feedback update together; success routes back to Requests. |
| Forbidden write | grid.set("Requests", ...) is rejected by the generated app guard. |
| Deep link | Opening each of the three package paths resolves the intended screen. |
| Responsive | Queue and action controls remain usable at desktop, tablet, and mobile widths. |
| Escape | The package's Dev mode control or Shift+Esc, Cmd+Esc, or Ctrl+Esc returns to the workbench. |
Also compare the final package against the baseline contract:
reads: Decision, Feedback, Requests, SearchRequests, SelectedRequest, TotalRequested
writes: Decision, Feedback, SearchRequests, SelectedRequest
capabilities: app.route, model.read, model.write
Do not grant job.run or connector.call merely because custom code could call those adapter
methods. Add a capability only when the workflow needs it, the model and host expose it, and the
review names the exact action.
8. Publish and keep regeneration safe
Publish the tested package through the model UI package workflow used by your Grid host, then open the model's App Home in a fresh session. Verify the package revision, home route, reads, writes, and requested capabilities in the publish review or stored manifest.
Publish Grid App in App Builder generates a fresh package from the current App tree. Treat it as regeneration: keep the custom React or Vue source and build configuration in version control, and make reapplying the custom entry part of the package pipeline. Do not leave the only custom copy inside generated output that a later App Builder publish can replace.
Publishing the UI package does not publish a grid namespace release, change model access policy, or deploy this documentation site. Those remain separate operations.
You are done when
- the custom entry uses Grid's generated React or Vue adapter;
- the generated contract assertion passes at startup;
- reads, writes, routes, and capabilities match the App Builder baseline;
- loading, empty, error, live-update, write-rejection, route, and responsive cases pass;
- the browser entry is compiled rather than raw JSX;
- the package opens as App Home and still returns to Dev mode;
- authored custom source survives future App Builder regeneration through version control and the package build pipeline.
Return to Build Grid apps for the architecture and trust comparison, or Publish to a grid namespace when the model itself also needs a versioned namespace release.