Builder guide
App Studio is coming soon: it is in development and testing and is not part of release 3.0. This page describes App Studio as it is being built, so names and steps may change before release.
This page is for the person who builds apps. You need app:create and app:update (the Apps.Editor role or higher) and a working Maestro assistant — the builder chat is Maestro on an app-bound session. If App Studio is disabled in the rail, it is 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 (
/:orgSlug/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 (
/:orgSlug/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 an org publishes them (needs
app:publish); after that everyone withapp:runcan Run, andapp:cloneholders 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
/:orgSlug/apps/:id/edit. Left: the chat. Right: Preview and Draft. Below: Versions.
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 and the functions:<effect> scope a binding needs). It cannot create, delete, share or publish apps — publish is your click. 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. One click does: read the draft's manifest.json → checkpoint → POST /appstudio/apps/:id/versions (build, app:update) → POST …/versions/:n/activate (app:publish). The success toast names any new scopes compared with the previously active version (the scope diff). 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); 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/{org}/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" }
}
}
}
| Field | Rules |
|---|---|
entry | Optional; defaults to src/App.tsx. Must default-export the root component. |
scopes | Required (an empty array means the app touches nothing). What the app requests; each viewer's real access is the intersection with their own permissions. |
bindings | Optional, at most 64. Kinds: topic (needs path; {org} resolves to the org slug), connector_function (needs connectionId, functionId, effect ∈ `read |
actions | Optional, at most 64. Kinds: data.publish (needs a topic binding), connector.execute (connector_function), pipelines.submit (pipeline). rung ∈ plain (default) | confirm (needs confirm.message) | approval. |
storage.collections | Optional, 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).
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 — 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-dom,@maestrohub/sdk,@maestrohub/app-ui(and their sub-paths) plus the app's own files. 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. - No external URLs, no credentials UI. The document CSP allows
connect-srcto 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.
usePermissionanswers 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_ALLOWEDno matter what the manifest holds; on the table without the row's scope (for exampleGET /uns/topics/accessiblewithoutuns:read) is403 INSUFFICIENT_SCOPEnaming the scope that would grant it. The socket is the same: an app connection may senduns:subscribe/uns:unsubscribe; any other frame is answered witherror+code: APP_FRAME_NOT_ALLOWED. Zero scopes mean zero. - 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/approveand…/reject— pass through a write guard that matches the request to a declaredactionsentry: none →ACTION_NOT_DECLARED; rungconfirmwithout the confirmation header →CONFIRMATION_REQUIREDwith the action's message; rungapproval→APPROVAL_REQUIRED(submit the pipeline that carries the approval gate instead). Every write carries anIdempotency-Keyand an in-flight lock, so a double click or retry never writes twice. - Alarms.
alerts.recent/alerts.historyread the platform's alert feed anduseAlertsstreams fires and clears live (subscribe_alertson the app socket) — all underuns:read, since alert visibility inherits topic read.alerts.ackacknowledges one alarm with the viewer's ownalert:ackauthority; it is a declared action ("alerts"binding — org-wide by design, an alarm is addressed by topic id — plus analerts.ackaction with a rung) and needs thealerts:ackscope. Shelving is not exposed to apps. - Approvals.
approvals.list/approvals.getread the human decision inbox for pipeline gates (approvals:read;approvals:decidecarries it), andapprovals.approve/approvals.rejectdecide one with the viewer's ownapproval:decideauthority — declared actions (an org-wide"approvals"binding plus anapprovals.decideaction 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.refis the topic path — the way from a machine to its live data),plant.walk(everything under a place, or along links, withvaluesread in one call),plant.numbers(a figure across a set, ordered) andplant.timeline(events, live alarms) read the knowledge module's plant underentities:read— the same tools an agent asks asplant_*. Every answer is anEnvelope:result, pluscoverageandhiddento show. Read-only: apps never propose or decide changes; the relationship-graph routes are not exposed. Key per-machine app state by the thing's stableid, not a topic path. (Until 2026-09 this wasentities.*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/cancelledplus per-node progress, withmodeseparating real runs from sandbox test runs — underpipelines:read(pipelines:executecarries 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 fromGET /engine/executions. The door pairs withpipelines.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 — an app can neither impersonate the platform nor reach beyond its users. Severity isinfo/warning(nevercritical), thekeydedupes retries of the same human event, and a declarednotifications.notifyaction (anotificationsbinding + rung) is required — no scope, because the feed has no grantable verb by design. Org 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_LIMITEDwithRetry-After) and each app socket may hold at most 200 live subscriptions (subscribe_limit). Never pollapiFetchon a timer for live values; useuseTopic. - 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-uirenders 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}, org: {id, slug}, permissions, theme: {mode}, disabled? }
usePermission(scope) // { granted: boolean, reason?: string } — fail-closed
useTopic('mHv1.0/<org>/…') // { 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
connectors.execute({ connectionId, functionId, params? }, { confirmed? }) // functions:<effect> matching the function
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
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
<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 usePermission(<scope>) 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; styling is inline or the UI kit.
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.
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. Dismissal is stored server-side per viewer.
Kiosk and pairing, from your side
Every published app has a stable full-screen address /:orgSlug/apps/<slug>/kiosk (the kiosk button on the card and on the Run page). To put it on an unattended wall, ask an 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. If the display's role alone cannot open the app, share the 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 — 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.