Operator 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 runs the MaestroHub instance. Every path, knob and permission below is taken from the code as of this release; where a source file is named, that is where to look when something does not match.
Enabling App Studio
Two switches, both required:
- Licence feature
app_studio. The frontend hides the Apps section and shows an "App Studio is not enabled" page when the feature is absent; the backend refuses every/api/v1/appstudio/*call with403 APP_STUDIO_DISABLED. In a dev build the feature is turned on by listing it undermodules.license.dev.featuresinapps/backend/context-engine/config.yaml. modules.appstudio.enabled: truein the server configuration.
The modules.appstudio block, with every knob and its default (modules/appstudio/config/config.go, ApplyDefaults):
modules:
appstudio:
enabled: true
# AES key (16/24/32 bytes, raw or base64) that encrypts stored Git remote
# tokens. Lite generates a random one on first boot and persists it as the
# secret file "appstudio_encryption_key" (context-engine/config/secrets.go);
# set it explicitly on Enterprise. Empty = Git remotes can be configured
# only without a token, and the configure call says so.
encryptionKey: ""
# The hostname the app runtime origin is served from. Empty = apps cannot
# be served (authoring still works; the module logs it at Start).
sandboxDomain: sandbox.example.com
# The platform UI's public origin — pinned into every app document's CSP.
platformOrigin: https://maestrohub.example.com
# Only for split-port setups (see "Sandbox domain"). Leave unset in
# single-listener production.
sandboxOrigin: ""
storage:
type: sqlite # or postgres
sqlite:
path: ./data/appstudio.db
postgres:
connectionString: ""
maxOpenConns: 10
maxIdleConns: 5
git:
path: ./data/appstudio-git # one repo per (org, app)
retention:
enabled: false # opt-in app-storage sweeper
interval: 1h
batchSize: 500
rateLimit:
enabled: true
requestsPerSecond: 50
burst: 100
The retention sweeper is opt-in. If a builder publishes a manifest that declares retention on a storage collection while storage.retention.enabled is false, the publish succeeds and the module logs a WARN saying documents will not be purged (application/versions.go). Nothing is purged until you turn the sweeper on.
The module also announces every on-but-inert switch at Start and in GET /health (module appstudio, components serving, rate-limit, storage-retention, token-minting, license-gate, lifecycle-events, metrics). The full table is in docs/appstudio-runbook.md §3.
Sandbox domain
Apps are served from a different origin than the platform UI, on the same listener: an echo Pre middleware routes any request whose Host is sandboxDomain or a subdomain of it to the sandbox handler and nothing else (modules/appstudio/infrastructure/sandbox/handler.go). The sandbox origin serves exactly three things per app — GET /apps/<orgID>/<slug>/ (the document), boot.mjs and bundle.mjs — plus the builder's draft previews under /apps/<orgID>/<slug>/draft/<nonce>/. Everything else on that host is a 404.
What you must set:
sandboxDomain— a hostname that resolves to the same listener as the platform. Without it,run_urlon every app is empty, the Run and Kiosk pages show "App serving is not configured — an operator must set modules.appstudio.sandboxDomain (and platformOrigin)", and the module logssandboxDomain not set — apps cannot be servedat Start.platformOrigin— the UI's public origin (scheme://host[:port]). It is written into the CSP of every app document asframe-ancestors(only the platform shell may embed apps),script-srcandconnect-src(vendor bundles and the API live on the platform origin; the WebSocket scheme is added automatically). If it is empty the CSP isframe-ancestors 'none': app documents render standalone but the platform cannot embed them, and the module warns at boot.sandboxOrigin— optional full origin override used when composing run URLs and the WebSocket origin allowlist. Unset, the run URL uses the platform origin's scheme and port onsandboxDomain, which is correct when UI and API share one port. Set it only in split-port setups.
The one function that decides where apps live is Config.EffectiveSandboxOrigin() — the run-URL composer, the CSP and the WebSocket origin allowlist all derive from it, so they cannot disagree.
Dev setup. *.localhost resolves to loopback in modern browsers, so the shipped dev config needs no DNS:
sandboxDomain: sandbox.localhost
platformOrigin: http://localhost:8086 # vite dev server
sandboxOrigin: http://sandbox.localhost:6163 # the API listener serves the sandbox
The document's CSP is default-src 'none'; script-src 'self' <platformOrigin> '<import-map hash>'; connect-src 'self' <platformOrigin> <ws(s)://platformOrigin>; img-src 'self' data:; style-src 'unsafe-inline'; frame-ancestors <platformOrigin>; base-uri 'none'; form-action 'none'. If platformOrigin does not match the origin the shell is actually served from, the browser refuses to embed the frame (frame-ancestors), refuses to load the pinned vendor bundles (script-src) and refuses the app's API and socket calls (connect-src). The symptom is a blank frame with CSP errors in the browser console — see runbook §G.
Every sandbox response is Cache-Control: no-cache (draft bundles: no-store), so a rollback or kill switch takes effect on the next open, not after a cache TTL.
Permissions
The app entity has these catalog actions (grep "app: in authz/domain/builtin_roles): app:create, app:read, app:update, app:delete, app:run, app:publish, app:clone, app:share, app:transfer, app:backup.
Built-in roles that hold them:
| Role | Verbs | Notes |
|---|---|---|
Apps.Viewer | app:read, app:run | Opens and runs published apps. What an app then shows is bounded by the viewer's own resource permissions. |
Apps.Editor | app:create, app:read, app:update, app:run, app:publish, app:clone | Builds and iterates. No delete, no share. |
Apps.Admin | Editor's verbs plus app:delete, app:share, app:backup, and role:create/read/update/delete/assign/revoke | Full lifecycle except ownership transfer. |
Organization.Admin | every app:* verb including app:transfer and app:backup | Ownership handoff lives here, like every other resource type. |
Persona.DataEngineer | the Apps.Editor set | Composed persona. |
Apps.Viewer/Editor/Admin are feature-tier roles (Tier: TierFeature). The roles catalog serves them only when the advanced_access_control licence feature is on (authz/roles_catalog_tier_http_test.go). On the Foundation tier, Organization.Admin still carries the full app:* set, and a Member reaches an app through a share on that app (see Sharing). See Users & Roles for the tier model.
Which verb gates which surface (modules/appstudio/infrastructure/http/handler.go):
| Surface | Verb |
|---|---|
| List, open, read draft files, checkpoints, validate | app:read |
| Write draft files, checkpoint, preview, Ops page, kill switch, Git remote config, edit seat | app:update |
| Create version | app:update; activate a version, Git push, fleet compatibility scan, provision a showcase app |
| Token exchange (run an app), first-open notice | app:run |
| Clone | app:clone |
| Share, share-time access preview | app:share |
| Delete | app:delete |
| Org-wide backup / restore | app:backup |
GET /appstudio/apps/by-slug/:slug carries no type-level guard on purpose: the handler answers from the caller's per-app allowed_actions and returns 404 for an app they cannot read, so a display that only holds a share on one app can still open it.
Display profiles: kiosk and device pairing
Kiosk route
Every published app has a stable full-screen address: /:orgSlug/apps/:slug/kiosk (apps/frontend/context-engine/src/pages/apps/KioskPage.tsx; the segment also accepts the app id). The page hides the shell chrome, offers an "Enter full screen" button (the Fullscreen API needs a user gesture), keeps a slim provenance strip ("built by X, not part of MaestroHub"), and re-fetches the app every 30 s so a disabled or unpublished app comes back by itself once fixed. Every non-running state is a room-readable message: not found, disabled, not published, serving not configured.
The Apps page and the Run page both carry a kiosk button; the Run page's viewer sees the same first-open notice a kiosk does.
Device pairing
An unattended screen should not hold a person's session. Pairing gives it its own identity — an AgentAccount with a fixed read-only scope set and the viewer roles you choose — through a device-code flow in the spirit of RFC 8628 (modules/auth/domain/device.go, modules/auth/application/device_pairing_service.go).
On the screen: open /pair (no session needed). The page requests a code, shows an 8-character user code (alphabet omits 0/O/1/I/L), and polls until an administrator decides. It tells the screen when a code expired, was declined, or was already used, and offers "Get a new code".
In the admin UI: Identity & Access → Devices (/:orgSlug/identity-access/devices; the route needs agent:read and the access_control feature). Click Pair a display, type the code, and the dialog shows what is asking to pair (browser, IP, code expiry). Choose:
- Display name (defaults to
Display <code>). - App to show — a published, enabled app; the landing path becomes
/apps/<slug>/kiosk. With no app chosen the screen lands on/apps. - Roles the display holds — checkboxes limited to the closed allowed set.
Approve creates the agent, grants the fixed scopes, binds the roles, issues the token and parks it for the screen's next poll. Deny rejects the code (the screen learns on its next poll and shows the refusal); a code nobody acts on expires on its own. The Devices table lists status, landing path, roles, paired/last-seen times and token expiry; the trash icon revokes (ends the token, the agent and its role bindings — the screen returns to /pair on its next request). Deny is exposed on the API (POST /auth/devices/deny); the dialog offers Approve and Cancel.
Pairing facts from the service:
| Item | Value | Source |
|---|---|---|
| Fixed scopes | apps:read, apps:run, uns:read, dashboards:read, organizations:read, monitoring:read — not configurable; a screen never gets a write scope | DeviceScopes |
| Allowed roles | Apps.Viewer, Data.Viewer, Data.StreamViewer, Connect.Viewer, Automate.Viewer, Fleet.Viewer, Organization.Member | DeviceAllowedRoles |
| Default roles | Apps.Viewer + Data.StreamViewer (the latter carries topic:subscribe; without it a wall has no live values) | DefaultDeviceRoles |
| Permissions to pair / list / revoke | agent:create (lookup, approve, deny), agent:read (list), agent:update (revoke) | device_handler.go |
| Public routes | POST /auth/devices/code, POST /auth/devices/token — rate-limited per source IP (20/min) and paced by the poll interval | device_handler.go |
| Audit | device.paired, device.denied, device.claimed, device.revoked | emit |
Configuration under modules.auth.devices (modules/auth/config.go, defaults from defaultDevicesConfig):
modules:
auth:
devices:
codeTTL: 10m # how long a pairing code stays approvable
pollIntervalSeconds: 5 # minimum spacing between the screen's polls
tokenTTL: 8760h # agent token lifetime; capped at 365 days
Organization.Member is the Foundation-tier path: the feature-tier viewer roles need advanced_access_control. A Member display reaches an app through a share to its agent subject — the dialog's hint "Share the app with the display if its role alone cannot open it" means exactly that. The Devices dialog only offers roles the instance's licence actually serves.
Provider config
The builder route checks Maestro before it renders: if the maestro licence feature is off, the module is disabled, or no provider/model is configured, builders see a page that names the fix (and can still run apps). Viewing and running apps never depends on Maestro.
:::uration for the builder
App Studio adds no parallel LLM configuration. The builder page embeds the regular Maestro chat panel on an app-bound session with the seeded App Studio Builder playbook pinned (modules/maestro/seed/playbooks/14-app-studio-builder.yaml; app-kind sessions refuse to run without it). Everything about providers and spend is Maestro's:
- Providers and model catalog: AI Assistant → Providers (
/:orgSlug/maestro/settings, needsmaestro_settings:read). - Spend controls and usage: AI Assistant → Usage (
/:orgSlug/maestro/usage) — the per-org enable toggle, monthly token budget and fallback account live there. Each app's own builder spend (last 30 days) is shown on the app's Ops page, read from Maestro usage under theapp:source prefix. - Prerequisites:
modules.maestro.enabled: true, a provider configured, and themaestrolicence feature. Per theconfig.yamlcomment andmodules/maestro/module.go: enabled with no provider key, the module logs a warning and the send-message endpoint answers503until a provider is set in the admin UI; enabled with a provider but no licence feature, routes answer403 MAESTRO_DISABLED.
Maestro on Enterprise (Helm)
The chart runs Maestro as its own pod (maestro.enabled, rendered by the umbrella under templates/maestro). Nothing about providers changes: accounts are still created at runtime under AI Assistant → Providers and stored in maestro_db, encrypted with secrets.maestroEncryptionKey — back that key up with the database. The builder's tool surface is assembled across pods: every module pod serves its MCP tools on the message bus (global.mcp.modules, filtered by each module's enabled), Maestro fans out over them, and /api/v1/mcp — the URL external MCP clients use — is served by the maestro pod as the same aggregate. maestro.replicas may be raised: sessions live in Postgres, per-user rate-limit buckets in the coordinator's KV, and the session-retention sweeper (maestro.retention.schedule) is leader-elected. With maestro.enabled: false the Builder page reports that Maestro is not deployed; viewing and running apps is unaffected.
The Apps → Builder route is gated on app_studio only, not on maestro. With App Studio licensed and Maestro not, the builder page opens, the draft/preview/versions panels work, and the chat panel's requests are refused by the Maestro backend as above. The builder is what needs Maestro; viewing, sharing, cloning and running published apps do not.
Fair use
Three primitives bound an app's footprint; each announces itself when it bites (docs/architecture/app-studio/LOAD-TESTS.md §1):
| Surface | Knob | Refusal |
|---|---|---|
| Every API call made with an app token | modules.appstudio.rateLimit.{enabled, requestsPerSecond, burst} — one token bucket per (org, app), shared by all viewers of that app; defaults 50 rps / burst 100 | 429 APP_RATE_LIMITED with Retry-After |
| Live UNS subscriptions per app-token socket | websocket.appMaxSubscriptionsPerConnection (default 200; user sessions are uncapped) | uns:error code subscribe_limit |
| History reads (versions, checkpoints) | fixed LIMIT of 200 newest rows in SQL | silently bounded |
The bucket is keyed on the app, not the viewer: fifty displays of one app share one budget, so a polling loop in the app is charged to the app, not to the org. Sizing evidence and the load harness (tools/appstudio-load) are in LOAD-TESTS.md.
Operations
- Overview — App Studio's front door (
/:orgSlug/studio, where the rail's App Studio icon lands): one sentence on the state of things; a needs-attention queue holding only what the reader can fix — forapp:publishholders one row while App Studio refuses, rate-limits or fails calls (or the monitoring store does not answer), pointing at the Health panel below it; for anyone who can update an app, each switched-off app, linking to its Operations page; then Your apps (starred first, then drafts you can finish, then the latest changed — each with Run and, for an editor, Builder) and, for people who can create, four starters to begin from. - Health panel — on the Overview, for
app:publishholders (there is no separate page; the old/:orgSlug/studio/healthaddress lands on the Overview): calls, denied, rate-limited and errors per minute, mean platform latency, and live/draft/disabled app counts for the last minute, read from the monitoring store. When the store does not answer the panel says so instead of showing zeros. - Per-app Ops page —
/:orgSlug/apps/:id/ops(app:update): per route family (UNS data, connector functions, pipelines, dashboards, app storage) calls, denials, errors, p95 with the platform-vs-connector split, the notable-call ring, and the app's builder spend. Numbers are per replica since process start; a restart empties them. - Kill switch — the Ops page's first card. Disabling sets
disabled: trueon the app (PUT /appstudio/apps/:id,app:update, audited asapp.disabled): viewers get the "App disabled" page immediately and no new app token is issued; sockets already open keep receiving until their token expires (app tokens live 5 minutes,AppTokenTTL). Nothing is unpublished; Enable brings the same version back. The builder keeps working on a disabled app, publish included. - Metrics — all under
maestrohub_appstudio_*(modules/appstudio/infrastructure/metrics/metrics.go):app_calls_total{org,family,outcome},app_call_platform_msandapp_call_upstream_mshistograms{family},app_denials_total{org,code},rate_limited_total{org},token_exchanges_total{org,outcome},apps{state}. Theorglabel is bounded to the first 100 orgs seen per process; overflow folds intoother. - Alerts and runbook —
docs/appstudio-alerts.yamlships five Prometheus rules (AppStudioRateLimiting,AppStudioDenialsSpike,AppStudioErrorRate,AppStudioPlatformLatency,AppStudioTokenExchangeFailing, plus the informationalAppStudioAppsDisabled); each points at a section ofdocs/appstudio-runbook.md, which also lists the start-up announcements and the common workflows. Load the rules withrule_files: [appstudio-alerts.yaml]. - Audit — every app call lands an audit row with
app_idandapp_version_id; filter the Audit page by acting app.
Backup and restore
App Studio → Operate → App Backup & Restore (/:orgSlug/studio/backup) carries the App Studio backup card; the sidebar entry is shown to app:backup holders. (It used to sit on System Management → Backup & Migration, which now carries bundle export/import only.) Both controls need app:backup (granted with Apps.Admin and Organization.Admin) and are shown disabled with that reason otherwise.
- Download streams
GET /appstudio/backup: a gzip'd tar (appstudio-backup/1) of the org's App Studio tables plus every per-app git directory and built bundles — the two must travel together, because a version row is a pointer to a commit. - Restore uploads to
POST /appstudio/restore?mode=refuse|replace.refuse(default) answers409 RESTORE_TARGET_NOT_EMPTYif the org already has any app;replacedeletes the org's App Studio data first and requires the destructive confirmation (the card's confirm dialog sendsX-MaestroHub-Confirmed-At). The archive'sorg_idmust equal the target org. Ephemeral tables (edit sessions, idempotency keys) are skipped and the report says so. Body capped at 2 GiB.
Restore is not atomic across tables and git; a mid-way failure leaves a partial organization and the error says to re-run with mode=replace. File-level backups must include both modules.appstudio.storage and storage.git.path — the module's /health backup-paths component and its Start log name both paths. Details and the automated drills: docs/architecture/app-studio/DATA-SAFETY.md.
Runtime versions
Every published version records the runtime it was built against (runtime-vN: the pinned react, react-dom, @maestrohub/sdk, @maestrohub/app-ui bundle set served at /appstudio-runtime/runtime-vN/ on the platform origin). A platform upgrade that ships a newer runtime changes nothing for a published app; republishing is the only way a version moves runtimes.
- The registry is compile-time static (
GET /appstudio/runtimes); the module logs the current runtime and its support window at Start. - A runtime is current until the next ships, then supported until release + 18 months, then sunset. An app pinned to a sunset runtime gets a
410teaching page ("published against runtime-vN, which this platform no longer serves — republish from the builder"); its source and versions are untouched. - Compatibility page — App Studio → Operate → Compatibility (
/:orgSlug/studio/compatibility), forapp:publishholders. The scan is collapsed by default because opening it runsGET /appstudio/fleet/compat, which rebuilds every published app (up to 200) against the current runtime and reportscompatible,deprecatedorbrokenper app.
Policy and machinery: docs/architecture/app-studio/SDK-VERSIONING.md.
Enterprise
On the Enterprise Helm deployment (deployment/context-engine/helm) App Studio is its own pod — the appstudio subchart, on by default — with the same module configuration as Lite under different keys. What is different from Lite:
- Two hostnames. Apps are served from
global.appstudio.sandboxDomain(a second DNS record pointing at your ingress controller; the chart renders a second Ingress for it, straight to theappstudiopod) andglobal.appstudio.platformOriginmust be the exact origin browsers use for the platform (https://<ingress.host>). Both are required together; the chart refuses a half-configured pair. With TLS,ingress.sandboxTLSnames the certificate for the sandbox host (cert-manager issues it from the same annotations). - Single replica. The per-app git store is a
ReadWriteOncePVC (appstudio.persistence, mounted at/data/appstudio), so the pod runs as one replica with theRecreaterollout strategy — a few seconds of app-serving downtime per upgrade. The chart refusesappstudio.replicas > 1. - Secrets.
secrets.appstudioEncryptionKey(16/24/32 bytes) is the FR-32 key Lite generates for itself; the Postgres databaseappstudio_dbis created by the release's db-init job and the connection is built fromglobal.db*. - Backup. Add
appstudio_dbtobackup.postgres.databases(it is in the default list) and enablebackup.appstudioto tar the git PVC nightly; restore the two together — version rows point at commits. The org-scoped archive on App Studio's Backup & restore page works unchanged. - Fair use and retention are the same knobs under
appstudio.rateLimitandappstudio.storage.retention. - Live data reaches apps through the
websocketpod, which admits the sandbox origin automatically (derived from the sameglobal.appstudiovalues). The runtime bundles are served by the UI pod with a CORS header, since the sandbox origin imports them cross-origin. - The broker credential is two things.
emqx.brokerAuthz.clientId/clientSecretis thebroker.authzOAuth2 client EMQX presents on every authentication and ACL callout — and the chart hands the same pair to the UNS pod as its own broker login (<release>-uns-broker-credential, oruns.mqtt.external.credentialSecret). It can only be provisioned once an admin exists (POST /api/v1/system/uns/settings/broker-credential, the UNS broker page's button), so a fresh install is two-phase: install with placeholder values, provision,helm upgradewith the pair. UNS broker settings are runtime settings seeded from the chart on first boot only — on a release that booted before the pair existed, also save it on the UNS broker page (PUT /api/v1/system/uns/settings,broker.external.username/password), or the platform keeps logging in to EMQX with no credential andGET /api/v1/system/uns/settings/statusstays "server requested disconnect". - The builder is unavailable until Maestro has its own Enterprise unit (#4561): the Builder page explains this, and viewing, running, sharing, templates and the showcase work in full.
- Alerts for App Studio are part of the chart's
PrometheusRule(maestrohub.appstudiogroup) whenalerts.enabled.
To run the Playwright suite against a local Enterprise install, see deployment/context-engine/helm/values-e2e-local.yaml and deployment/local/e2e-enterprise.sh. The gap analysis that preceded this is docs/architecture/app-studio/ENTERPRISE.md.