Skip to main content
Version: 3.0

Builder guide

Beta: create an App Studio app with Maestro or your own AI client, declare what it may do in its manifest, then publish, share and show it on a kiosk.

Needs
Apps.Editor role or higher
Storage caps
256 KB per document · 1 GiB shared per app · 10 MB per attached file
Fair use
50 rps, burst 100, shared by all viewers
Beta

App Studio is in release 3.0 as Beta: a paid add-on that no edition includes by default. A licence or a trial licence that includes the app_studio feature switches it on. The module is on by default, so the licence decides. Names and steps may change while it is in Beta.

This page is for the person who builds apps. You need app:create and app:update (the Apps.Editor role or higher) and something to build with: either the Maestro assistant (Maestro is a separate Beta in release 3.0, early access on request), whose builder chat is Maestro on an app-bound session, or your own MCP client through the Build with your AI client panel below. If App Studio is not in the rail, or a link opens an "App Studio is not enabled" page, it is not licensed or not enabled on this instance; ask your operator (Operator guide).

Creating an app​

App Studio's sidebar has two ways in, under Create:

  • New app (/:workspaceSlug/apps/new): describe the app in a sentence; it is created named from the sentence, and the sentence is handed to the builder's chat.
  • Templates (/:workspaceSlug/studio/templates): pick a starter card; the app is created named after the template (rename it in the builder). Each card shows its description and the scopes its manifest requests. The catalogue is documented on the Templates page.

Creating navigates straight to the builder. Other ways to get an app:

  • Showcase: two SDK dogfood apps on the New app page. The first open in a workspace publishes them (needs app:publish); after that everyone with app:run can Run, and app:clone holders can Clone.
  • Clone: the copy icon on a card: copies the published version (or the draft, if there is no published version and you hold app:update) into a new app of your own and opens the builder.

The builder page​

/:workspaceSlug/apps/:id/edit. Left: the chat, or the Build with your AI client panel. Right: Preview and Draft. Below: Versions.

Build with your AI client. The builder does not need MaestroHub's built-in assistant. When no AI provider is set up, or when you chose Build with your AI client on the New app page, the left side is a panel for your own MCP client (Claude Desktop, Claude Code, Cursor …): a Connect an AI client button that opens the connect page with the Apps toolset and this workspace chosen, a prompt to copy that names this app and carries your description, and a line saying when the draft last changed. The preview follows the client's edits within seconds. See Building an App Studio App.

Chat with Maestro. The panel is a Maestro session bound to this one app with the App Studio Builder playbook pinned. The agent can only list_files, read_file, edit_file, write_file and validate through the manage_app_source tool, always against this app's id, and look things up, read-only, with find_topics, find_pipelines (ids plus the Manual Trigger nodes pipelines.submit can fire) and find_functions (connection and function ids plus the effect, the functions:<effect> scope a binding needs and the params the function takes). It can run a read or discover function once with try_function to see the exact result the app will get; write and stream functions are tried by you, in the preview. It cannot create, delete or share apps. It can ask for a release, which waits for a person to approve, like every other way of publishing. The Try one of these… dropdown seeds the composer with a template's prompt.

Checkpoints per turn. After every agent turn the page creates a checkpoint of the draft attributed to the agent (a no-change turn is tolerated) and re-validates. The Draft card shows the checkpoint count.

Validate. Validation is the same compiler publish uses (esbuild embedded in the server, curated-import wall included), so "Draft builds clean" means it will publish. Diagnostics list file:line: message; the agent is told to fix every one and validate again before saying anything works.

Live preview. The draft is built server-side and served on the sandbox origin under a per-build nonce, booted with a draft token (manifest scopes ∩ your own permissions). A red draft keeps the last good preview. Runtime errors the preview throws appear under the frame with an Ask the builder to fix link; slow data sources (avg ≥ 1 s or max ≥ 2 s per API call) are listed too, so a slow query shows up here and not in production.

Publish is a request a person approves. Every way of publishing (the Publish button, the builder chat, an AI client's manage_apps → publish) builds a version and creates a release request (POST /appstudio/apps/:id/releases, app:update). The request records who asked and how, the files that changed against the live version, and the permissions viewers would start or stop granting. It is approved or rejected by a signed-in person who may publish the app (app:publish, POST …/releases/:rid/approve or /reject): the person who asked may approve their own request, and a token or an assistant never can. The Publish button opens the review straight after the request, so publishing your own work is one confirm with the changes in front of you. A request from an AI client or the chat shows as a banner on the builder (Release vN is waiting for approval) and on the Apps library for everyone who may publish the app; Review opens the same dialog. A rejection's reason is shown to the requester. Publish is disabled with a stated reason while validation runs or the draft is red; APP_BUILD_FAILED re-runs validation and shows the diagnostics.

Versions. Each row shows its number, message, time and the runtime it was built against; the header says which runtime you are publishing onto.

Checkpoints. The timeline lists every checkpoint newest first (seq, message, agent or user, time, bookmark). Restore rolls the draft back to that checkpoint as a new checkpoint (nothing is deleted, history is never rewritten), then re-validates and rebuilds the preview. Bookmark names a checkpoint inline (clear the name to remove it). Both need app:update.

Rollback. Any published version has Activate (app:publish, a signed-in person only); an AI client asks for the same thing with manage_apps → rollback, which is a release request like any other. The confirm lists that version's manifest scopes, the toast reports the scopes added or dropped. The serving version is marked current; a version built against a sunset runtime cannot be activated until it is republished.

Edit seat. Opening the builder acquires the app's single edit seat; it is renewed while the tab is open and released when you leave. If someone else holds it you see a banner naming them and since when (the chat is read-only and Publish/Restore say why), with Take over (their next save will fail with EDIT_SESSION_HELD). A seat nobody has renewed for five minutes is taken over silently.

The manifest​

manifest.json is what the app is allowed to do on a viewer's behalf. Field names are validated in modules/appstudio/domain/version.go, manifest_bindings.go and storage.go.

{
"entry": "src/App.tsx",
"scopes": ["uns:read", "uns:publish"],
"bindings": {
"oee": { "kind": "topic", "path": "mHv1.0/{workspace}/line3/oee", "label": "Line 3 OEE" },
"interlock": { "kind": "connector_function", "connectionId": "<id>", "functionId": "<id>", "effect": "write", "label": "release interlock" },
"flow": { "kind": "pipeline", "id": "<id>" },
"board": { "kind": "dashboard", "id": "<id>" }
},
"actions": {
"release": { "kind": "connector.execute", "binding": "interlock", "rung": "confirm",
"confirm": { "message": "Releases the line 3 interlock." } }
},
"storage": {
"collections": {
"notes": { "scope": "shared", "concurrency": "cas", "retention": "90d" },
"prefs": { "scope": "user" }
}
}
}
FieldRules
entryOptional; defaults to src/App.tsx. Must default-export the root component.
scopesRequired (an empty array means the app touches nothing). What the app requests; each viewer's real access is the intersection with their own permissions.
bindingsOptional, at most 64. Kinds: topic (needs path; {workspace} resolves to the workspace slug), connector_function (needs connectionId, functionId, effect ∈ `read
actionsOptional, at most 64. Kinds: data.publish (needs a topic binding), connector.execute (connector_function), pipelines.submit (pipeline), pipelines.cancel (pipeline; the app may cancel only the runs it started). rung ∈ plain (default) | confirm (needs confirm.message) | approval. as ∈ viewer (default) | app (see Actions that act as the app below). One action per write: a write runs as the one action that answers it, so two actions that write the same topic, function or pipeline (or two of a workspace-wide kind, such as two alerts.ack) are refused at validate and publish, naming both. To offer the same write two ways, use two targets.
storage.collectionsOptional, at most 64. Name ^[a-z][a-z0-9_]{0,63}$. scope ∈ shared (default) | user. writers ∈ viewers (default) | editors; shared scope only: editors restricts WRITES to the app's keepers (viewers holding app:update); reads stay open to every viewer, so it is authority over curation, never privacy. The object form of writers is reserved (role-writers via a binding, App Data Authority F2). concurrency ∈ cas | last-write-wins; defaults to cas for shared and last-write-wins for user. retention is "<n>d" or a Go duration such as "720h".

Storage caps (domain/storage.go): 256 KB per document, 1 GiB per app for shared scope, 10 MiB per (app, user) for user scope, list limit 200. A collection that is not declared does not exist for the app (404, never 403).

Attachments: a stored record can carry photos and PDFs (collection.attachments(key) in the SDK; POST /api/v1/appstudio/storage/:collection/:key/attachments with the raw file as the body). Save the record first; a key with no document is refused with ATTACHMENT_RECORD_MISSING. The bytes decide the type (JPEG, PNG, WebP, PDF, or a binary glTF 2.0 model .glb; anything else is ATTACHMENT_TYPE_NOT_ALLOWED). Models must be uncompressed: Draco, meshopt and KTX2 need workers or WebAssembly, which the app sandbox does not allow. Caps: 10 MB per photo or PDF, 25 MB per model, and 1 GB per app by default, set under modules.appStudio.config.attachments (maxFileBytes, maxModelBytes, maxAppBytes), and at most 50 files per record. A record's files go when the record goes: a delete, the collection's retention, deleting the app, or erasing a person's own data. The collection's writers rule applies to adding and removing files. Adding and removing a file on a shared record is audited (app.storage_attachment_added / app.storage_attachment_removed), never the bytes. Attachments are in the workspace backup.

Governance: every successful write or delete on a shared-scope collection lands in the platform audit trail (app.storage_written / app.storage_deleted under the appstudio module, with the viewer as actor, with collection, key, revision, and size; never the value). Per-viewer (user-scope) documents are deliberately not audited: personal read-marks and preferences are noise, not governance. Every document additionally carries created_by / updated_by / revision, so "who last changed the shifts" is answerable even outside the audit page.

Curated collections (writers: "editors"): the platform enforces the writer check server-side on every write and delete (the viewer must hold app:update on the app, asked of authz per write, fail-closed) and refuses everyone else with COLLECTION_WRITERS_ONLY and a remediation. The UI knows before the click: useAppContext().canEditApp reports the keeper flag (from the token exchange), so curation cards render disabled-with-reason for operators. See the App Data Authority design record for the full family (role-writers, reader restriction, group copies; all deferred, grammar-compatible).

What apps can and cannot do​

  • Curated imports only. The build resolves react, react/jsx-runtime, react-dom, react-dom/client, @maestrohub/sdk, @maestrohub/app-ui, @maestrohub/scene (the 3D production-line kit; its spec lives in src/scene.json, which validate and publish check field by field), three (three.js r170) and three of its add-ons (three/addons/controls/OrbitControls.js, three/addons/loaders/GLTFLoader.js, three/addons/environments/RoomEnvironment.js) plus the app's own files. Only these exact names: an unlisted sub-path fails the build too, because the browser would have no file for it. Any other bare import fails the build with a message naming the import and the curated set: there is no npm, no CDN, no external URL. Apps compile inside the MaestroHub binary; nothing is fetched. Each name imported from @maestrohub/sdk, @maestrohub/app-ui or @maestrohub/scene must be one the package exports: a misspelt or unknown name is refused at validate and at publish, naming the file and the closest export, because the bundle would build and the app would then never start in the browser.
  • No external URLs, no credentials UI. The document CSP allows connect-src to the platform origin only, and the playbook forbids UI that asks for passwords or tokens.
  • Never more than the viewer. The app token is manifest scopes ∩ the viewer's RBAC, re-minted every few minutes (server TTL 5 minutes); a permission change reaches the app at the next re-issue. usePermission answers fail-closed.
  • Only the SDK's surface, and only with its scope. An app token reaches the routes the SDK documents and nothing else: off the table is 403 APP_ENDPOINT_NOT_ALLOWED no matter what the manifest holds; on the table without the row's scope (for example GET /uns/topics/accessible without uns:read) is 403 INSUFFICIENT_SCOPE naming the scope that would grant it. The socket is the same: an app connection may send uns:subscribe / uns:unsubscribe; any other frame is answered with error + code: APP_FRAME_NOT_ALLOWED. Zero scopes mean zero.
  • Actions that act as the app. Everything an app does acts as the person viewing it, within their own permissions. An action with "as": "app" (only data.publish, connector.execute and pipelines.submit) acts as the app itself instead, so someone who may run the app can press a button they could not do by themselves, such as an operator changing a line speed from a kiosk. This needs Advanced Access Control; without it, "as": "app" acts as the viewer. The app may do only what the person making the version live holds: approving a release (or activating a version) is refused with APP_REACH_NOT_HELD, naming each action, when that person does not hold what the version's "as": "app" actions add. The app is then given exactly that, and nothing more. Each such write is recorded with the app as the actor and the person it acted for; a confirmed one is recorded every time. The preview always acts as you, since a draft has not been approved. The viewer's sign-in still caps the scopes: a paired display's token carries no write scope, so on a wall these actions are off, and useActionPermission says why.
  • Undeclared writes are refused. The write doors (POST /uns/data/publish, POST /connections/:id/functions/:functionId/execute, POST /scheduler/trigger/:pipeline_id/:node_id, POST /uns/alerts/acknowledge, POST /engine/approvals/:id/approve and …/reject) pass through a write guard that matches the request to a declared actions entry: none → ACTION_NOT_DECLARED; rung confirm without the confirmation header → CONFIRMATION_REQUIRED with the action's message; rung approval → APPROVAL_REQUIRED (submit the pipeline that carries the approval gate instead). Every write carries an Idempotency-Key and an in-flight lock, so a double click or retry never writes twice.
  • Alarms. alerts.recent / alerts.history read the platform's alert feed and useAlerts streams fires and clears live (subscribe_alerts on the app socket), all under uns:read, since alert visibility inherits topic read. alerts.ack acknowledges one alarm with the viewer's own alert:ack authority; it is a declared action ("alerts" binding, workspace-wide by design because an alarm is addressed by topic id, plus an alerts.ack action with a rung) and needs the alerts:ack scope. Shelving is not exposed to apps.
  • Approvals. approvals.list / approvals.get read the human decision inbox for pipeline gates (approvals:read; approvals:decide carries it), and approvals.approve / approvals.reject decide one with the viewer's own approval:decide authority, as declared actions (a workspace-wide "approvals" binding plus an approvals.decide action with a rung). The agent-writes surface is not exposed to apps. There is no approvals live stream; poll the inbox gently.
  • The plant. plant.whatCanIAsk (sites, trees, types, what is measured), plant.find (by name, identifier or topic; topics: [...] for a whole list in one call), plant.describe (one thing in full; bindings[].series.ref is the topic path, the way from a machine to its live data), plant.walk (everything under a place, or along links, with values read in one call), plant.numbers (a figure across a set, ordered) and plant.timeline (events, live alarms) read the knowledge module's plant under entities:read, the same tools an agent asks as plant_*. Every answer is an Envelope: result, plus coverage and hidden to show. The relationship-graph routes are not exposed.
  • Writing to the plant. Three declared actions on a plant binding, each with the viewer's own authority, and each held for another person where a part is held to another person's approval (a part held to a reason needs note). plant.record({ note, events }) writes events in the log under entities:record: a reject counted, a lab result, a fill, a changeover, a work order, a calibration, a proof test, a shift. An event is an observation, so it lands at once for a viewer who may record. Alarms, trips and what the readings show come from the plant itself, so they are refused. plant.update({ note, facts }) sets facts on things under proposals:write. A fact changes what the model says a machine is, so it is a change set proposed as the viewer, and it lands at once only when the app also has proposals:decide and the viewer may decide proposals. Otherwise it waits under Review, and result.state says proposed with the reason in result.waiting: show that, because a proposed change has not happened in the plant yet. plant.declare(thing, mode, { note }) says what a thing is doing (maintenance, changeover, stopped; running clears it) under entities:declare, and applies at once. The Plant Operator role records, declares and proposes; Data Engineer and the administrators also decide; Member only reads the plant. Key per-machine app state by the thing's stable id, not a topic path. (Until 2026-09 this was entities.* over the old entity tree; see the SDK changelog.)
  • Executions. useExecutionStream(pipelineId) streams one pipeline's live run events over the app socket (subscribe_pipeline): execution.started/completed/failed/cancelled plus per-node progress, with mode separating real runs from sandbox test runs, under pipelines:read (pipelines:execute carries it, so an app that submits runs can watch them). The gate is the same pipeline-read authz + scope ceiling the HTTP pipeline reads run, per pipeline id. The honest boundary: manual and API-submitted runs emit the stream; scheduled and message-triggered runs don't; read those from GET /engine/executions. The door pairs with pipelines.submit: the operator who pressed the button watches the run land.
  • Notices. notifications.notify({ title, text?, severity?, key }) posts one notice to the person feed of the app's own people: its owner plus every viewer who has opened it (the first-open acknowledgement is the audience roll). The platform stamps the producer (appstudio.apps), prefixes the title with the app's display name, and addresses the audience, so an app can neither impersonate the platform nor reach beyond its users. Severity is info/warning (never critical), the key dedupes retries of the same human event, and a declared notifications.notify action (a notifications binding + rung) is required, with no scope, because the feed has no grantable verb by design. Workspace channels (Slack/Teams) hear app notices only when an operator explicitly subscribes a channel to them; readers mute them with one switch under notification preferences.
  • Fair use. All viewers of one app share a request budget (default 50 rps, burst 100 → 429 APP_RATE_LIMITED with Retry-After) and each app socket may hold at most 200 live subscriptions (subscribe_limit). Never poll apiFetch on a timer for live values; use useTopic.
  • Provenance is not removable. Run and kiosk pages always show "built by X, not part of MaestroHub" above the frame.
  • Native look. The shell sends its design tokens with the theme (in the context message and on every flip or variant switch), the SDK stamps them on the app document, and @maestrohub/app-ui renders from them: surface, text, borders, accent and font follow the shell's exact theme, every variant, without a reload. Status colours (ok / warn / critical) stay the kit's own. An app that runs standalone (a story, a test) gets the kit's built-in palette.

The SDK in one screen​

Full reference: SDK. The surface below is runtime-v1's, as the builder playbook describes it.

import { useAppContext, usePermission, useTopic, apiFetch, isRateLimited,
data, pipelines, connectors, storage } from '@maestrohub/sdk';
import { Page, Card, Button, ErrorNotice, MetricCard, Table } from '@maestrohub/app-ui';

useAppContext() // { user: {id, displayName}, workspace: {id, slug}, permissions, theme: {mode}, disabled? }
usePermission(scope) // { granted: boolean, reason?: string } — fail-closed
useTopic('mHv1.0/<workspace>/…') // { value, status: 'connecting'|'live'|'error', error?: {reason, remediation} } — needs uns:read
apiFetch<T>(path, init?) // platform API with the app token; throws ApiError {status, code, message, retryAfterSeconds?}
data.publish({ topic, value, source? }, { confirmed? }) // uns:publish; existing topics only
pipelines.submit({ pipelineId, nodeId, data? }, { confirmed? }) // pipelines:execute; manual-trigger nodes only; answers { status, execution_id }
pipelines.run({ pipelineId, nodeId, data? }, { timeoutMs?, onProgress? }) // fires and waits: { executionId, status, outputs } or PipelineRunError
usePipelineRun(action, { pipelineId, nodeId, cancelAction? }) // { run, loading, result?, error?, progress?, granted, reason?, cancel?, canCancel? }, the sibling of useFunction
pipelines.cancel(executionId) // a declared pipelines.cancel action; only runs THIS app started (APP_RUN_NOT_STARTED_BY_APP otherwise)
useHistory(topic, { since: '8h', limit? }) // uns:read; stored values over a window, { data: { records, total }, loading, error, refresh }
useAggregate(topic, { since: '24h', bucketWidth: '1h', agg: 'avg' }) // uns:read; buckets by avg|min|max|sum|count|avg_tw
useContract(topic) // uns:read; the topic's schema (unit, min, max, enum values) by path, null when untyped
useRatio({ numerator: { topic, agg: 'sum' }, denominator: { topic, agg: 'sum' }, since: '8h', bucketWidth: '1h' }) // a KPI such as yield
useStateDuration(topic, { mode: 'exact', since: '8h' }) // time in run / idle / fault
useEventCount(topic, { op: 'eq', value: 'fault', mode: 'transition', since: '8h', bucketWidth: '1h' }) // faults that started; needs a typed (schema-bound) topic
connectors.execute({ connectionId, functionId, params? }, { confirmed? }) // functions:<effect> matching the function
// resolves { success, data?, error?, durationMs, timestamp } — check success before reading data; a read needs a declared action too
connectors.run(input, opts?) // same call; success:false throws FunctionFailedError (code FUNCTION_FAILED, .result)
useFunction(action, { connectionId, functionId }) // { run, loading, result?, error?, granted, reason? } — run never rejects
useActionPermission(action) // { granted, reason? } — the scope AND the viewer's own right on the bound resource
connectors.catalog({ query?, limit? }) // connectors:read; the functions this viewer may read, for a picker (plant-language search)
plant.whatCanIAsk() / plant.find({ name }) / plant.describe(id, { include: ['values'] }) / plant.walk({ from, tree: 'physical', direction: 'down' })
// entities:read; every answer is { result, coverage?, hidden? } — a binding's series.ref is the topic for useTopic
plant.record({ note, events: [{ thing, kind: 'reject', name?, at?, payload? }] }) // entities:record + a declared plant.record action; in the log at once
plant.update({ note, facts: [{ thing, facts }] }) // proposals:write + a declared plant.update action; result.state 'applied' | 'proposed' (+ waiting)
plant.declare(thing, 'maintenance', { note }) // entities:declare + a declared plant.declare action
useClock() / formatDateTime(at, { show: 'time' }) / dayWindow() / sinceClock('06:00') // the plant's clock: shown in it, windows as UTC instants
useConnection() / usePendingWrites() / <ConnectionNotice /> // kiosk: stale values, records waiting to be sent
storage.shared.collection('notes') / storage.user.collection('prefs')
// get(key) | put(key, value, merge?) | list({limit, cursor}) | delete(key); declare the collection in the manifest first
collection.attachments(key) // add(file, name?) | list() | open(id, name?) | remove(id); photos and PDFs on a saved record
useAttachments(collection, key | null) / useAttachmentUrl(collection, key, id) // the list with add/remove, and an <img> URL for a photo
people.search({ search?, kind?, limit? }) // who can open this app: { items: [{ kind, id, name, has_picture }], truncated } — names, never emails
<PersonField label value onChange kind? /> / <Avatar person /> // the UI kit's picker over people.search
usePeople({ search }) / usePersonPicture(person) / labelOf(person) / initialsOf(person) / subjectOf(person) // a picker, its avatars, and the notify address
notifications.notify({ title, key, to: [subjectOf(person)] }) // a notice to named people or groups who can open the app
print({ title? }) // the browser's print dialog for the app: paper or Save as PDF; className "mh-no-print" / "mh-print-only"
zpl.label({ widthMm, heightMm }).text(x, y, s).barcode(x, y, s).qr(x, y, s).toString() // a Zebra label, sent through a TCP connector function
<ErrorNotice error={caught} title? onRetry? /> // render EVERY caught refusal through it (429, ACTION_NOT_DECLARED, t.error)

Rules the agent follows and you should keep when you edit by hand: every acting control is guarded with useActionPermission(<action>) (or useFunction, which carries it) and rendered disabled with reason, never hidden; destructive or physical actions get confirm=; live values come from useTopic, history from the API, never from app storage; the app is framed in Page and built from the UI kit (validate warns on a hand-written <input>, <textarea>, <select>, <button> or <table> and names the kit component to use).

3D production lines​

An app can show its line in 3D with the scene kit (@maestrohub/scene). The app describes the line in src/scene.json and renders <Scene spec={scene} />; the kit draws it and keeps it in step with the line's topics. The full reference is the scene kit page.

Use 3D for an overview: which station is down, which buffer is empty, where finished parts pile up, on an andon screen or a kiosk. Keep the numbers next to it, in cards, charts and tables. A useful twin screen has the OEE row, the running order's progress, the scene with a panel for the station you click, and a log of what just happened.

What the kit draws:

ItemWhat it showsBound to
Station (cnc, washer, tester, robot, generic)Its process inside see-through walls, a stack light, its own screen, a scrap binstate, good, bad, cycle topics
BufferAs many parts as its level, up to its capacitylevel
OutputFinished parts stacked on a pallet; full pallets move to a shipped stackcount
OperatorA person at their station who walks to a covered station when it faults and works on it until it recoversthe stations' states

Known state values are RUNNING, IDLE, STARVED, BLOCKED, FAULTED, MAINTENANCE (or CHANGEOVER) and OFF, in any case. Map other codes per station with states. A station can show a stored 3D model instead of the kit's shape: attach a .glb file (binary glTF 2.0, uncompressed, up to 25 MB) to an app record and point the station's model at it.

To change a scene with an AI client, the builder tool reads it with read_scene and changes it with edit_scene ops (add_station, update_station, move_station, remove_station, the same for buffers, outputs and operators, and set_quality). edit_scene writes the file only if the result is a scene the kit can draw.

Validate and publish check scene.json field by field and name the field that is wrong, for example stations[2].kind: "lathe" is not a kind. A scene the kit cannot draw is refused at publish, so it never goes live.

The scene keeps to the screen's limits: balanced quality by default (low for older kiosk PCs), no drawing while the app is off screen or in a hidden tab, and the same line as cards when the screen has no WebGL. For visuals the kit does not cover, three and three of its add-ons can be imported directly.

People, paper and labels​

The app's people. people.search() lists everyone who can open the app: its owner, the people holding a role that opens apps, the people and groups it is shared with, and the members of those groups. A person who has been denied the app, or whose account is suspended, is not listed. The list is read from access control each time (a picker may be up to 15 seconds behind a change), and it carries names and whether a picture exists, never an email. Use it for an "assign to", "hand over to" or "notify" picker; the UI kit's PersonField is that picker, with search-as-you-type and avatars (Avatar draws one person):

const [to, setTo] = useState<Person | null>(null);
<PersonField label="Hand over to" value={to} onChange={setTo} />
// or a custom list: usePeople({ search }), usePersonPicture(person) ?? initialsOf(person), labelOf(person)
await notifications.notify({ title: 'Handover for you', key: `handover.${shiftId}`, to: [subjectOf(picked)] });

A notice with to goes only to the people and groups named. Each one must be able to open the app; otherwise the call is refused (403 NOTIFY_OUTSIDE_REACH) and nobody is notified.

Printing and PDF. An app prints itself: a Print button calls print({ title }), the browser's print dialog opens over the app, and the operator prints on paper or picks Save as PDF (the title becomes the file name). The page prints in light colours whatever the theme. Mark buttons and filters with className="mh-no-print", put a header or signature line in an element with className="mh-print-only", start a new page with mh-page-break, and keep a card on one page with mh-keep-together.

Labels on a Zebra printer. zpl.label({ widthMm, heightMm, dpi? }) builds the label in millimetres (text, Code 128 barcode, QR code, boxes, copies); toString() is the ZPL. The label reaches the printer through a connector function, so no driver is needed:

  1. An operator adds a TCP connection to the printer (its address, port 9100, default encoding utf8).
  2. On it, they save a function with operation Send, Data ((zpl)) and encoding utf8. With the connection's default encoding bytes, the text would be read as base64 and the print refused.
  3. The app declares a write action for that function and the functions:write scope, then sends the label:
const label = zpl.label({ widthMm: 100, heightMm: 50 })
.text(5, 5, `Batch ${batch.id}`, { heightMm: 6 })
.barcode(5, 15, batch.id, { heightMm: 15 })
.qr(70, 5, batch.url)
.toString();
const printLabel = useFunction('print-label', { connectionId, functionId });
<Button disabled={!printLabel.granted} title={printLabel.reason} onClick={() => printLabel.run({ zpl: label })}>Print label</Button>

The plant's clock, and kiosks that lose the network​

Time. MaestroHub stores and answers every time in UTC. An app shows it in the workspace's clock: the Time zone a workspace administrator sets on the workspace (or the viewer's browser zone when none is set). useClock() says which zone and where it came from (timeZoneSource is workspace or viewer), and the viewer's browser language (locale). formatDateTime(at, { show }) writes an instant in that clock and language ("15.10.2026 08:00"), formatNumber(n, digits) writes a number the viewer's way ("1.234,5"). For a read, dayWindow(daysAgo) and sinceClock('06:00') turn "today", "yesterday" and "since the shift started" into UTC start and end, so a day across a clock change is the right 23 or 25 hours.

A dropped connection. A value on the wall stays when the live connection drops: useTopic keeps the last one and says stale: true, with message.receivedAt saying when it came. useConnection() is the connection as a whole (live, or reconnecting since when it dropped). Put <ConnectionNotice /> from @maestrohub/app-ui at the top of a kiosk app: it says "Live values paused since 08:03" in the plant's clock, and how many records wait to be sent.

Writes while offline. A write that records what happened (a storage save or delete, plant.record, notifications.notify) waits in the outbox and is sent once the connection is back, in order, with the same idempotency key, so it lands once; plant.record stamps each event's time when it is made. usePendingWrites() lists what waits; pendingWrites.flush() tries now. Anything that moves equipment or decides something (connectors.execute, data.publish, pipelines, plant.declare, approvals) and any write on the confirm rung is never kept: offline it fails at once with CODE_OFFLINE ("not sent, nothing was done"), and the viewer presses again when the connection is back. The outbox lives as long as the page.

Sharing​

The share icon on an app card (needs app:share) opens the platform Share dialog with the verbs the catalog allows for apps: the universal read/create/update/delete/share, plus run (the viewer verb) and publish/clone (collaborator verbs). Subjects can be users, groups, roles, clients or a paired display's agent; grants can be time-bound. Granting a share needs the advanced_access_control licence feature. Without it the dialog still lists the app's existing shares and lets you revoke them, and says why it offers no grant form.

Access preview. As you pick a subject, the dialog shows what that audience will reach: "This audience will reach 3 of 5 bound resources", with a per-binding verdict from the same authz check the viewer would get. An app with no bindings says its access follows the audience's own permissions at run time; an unpublished app has nothing to preview.

First-open notice​

The first time a viewer opens an app they do not own (Run or kiosk), a dismissible notice names who built it and lists the app's effective scopes for that viewer: the token's granted set, not the manifest's wish list. When some of its actions act as the app itself for that viewer, the notice lists them ("publish to Filler target", by the binding's label) and says they can do what the viewer could not; the Run page's provenance line says the same instead of "It acts with your permissions only". Dismissal is stored server-side per viewer.

Kiosk and pairing, from your side​

Every published app has a stable full-screen address /:workspaceSlug/apps/<slug>/kiosk (the kiosk button on the card and on the Run page). To put it on an unattended wall, ask a workspace or identity administrator to pair the display under Identity & Access → Devices and pick your app; the display then gets its own read-only identity and opens your app's kiosk page. The display's default role, Workspace.Display, opens every published app in the workspace. With advanced_access_control you can instead share just this app with the display. Details: Operator guide → Display profiles.

Git mirror​

The Git button in the builder configures one external remote per app: HTTPS URL, branch (default main), username and a write-only token (the server never returns it; leave the field empty to keep the stored one, or clear it explicitly). Push sends the active version to the remote; it is one-way by construction; the dialog shows the last push (version, time, SHA) or the last error. Configure needs app:update; push needs app:publish. Tokens are encrypted with modules.appstudio.encryptionKey; if the operator has not set one, remotes can be configured only without a token and the save says so.

Your app's Ops page​

Operations (in the builder header, or the activity icon on the card; app:update) shows what your app asked the platform for on its viewers' behalf: calls, denials, errors and p95 per family (UNS data, connector functions, pipelines, dashboards, app storage) with the platform-vs-connector time split, the notable-call ring, your builder spend over the last 30 days, and the kill switch. A 429 row under client errors means the app is polling where it should subscribe.

App storage. The Ops page lists the collections the serving version's manifest declares (scope, retention) with Export NDJSON per collection; shared collections export whole, user-scoped ones export only your own documents (app:update).

Runtime pin per version​

Every version you publish is pinned to the runtime shown in the Versions header ("publishing onto runtime-vN"). A platform upgrade never rebuilds your app; a version keeps loading the bundles it was built against until you publish again. A badge on each version says current, supported until <date>, or, for a sunset runtime, that viewers now see the "no longer served" page and a republish moves the app to the current runtime. Operators can also run the fleet compatibility scan from the Apps page. Policy: docs/architecture/app-studio/SDK-VERSIONING.md.