Build workbench UI
A mini app's workbench UI is a built ES module artifact that the API serves and the running workbench loads, replaces and unloads without reloading the app. Use this page to create a mini app of your own, reach the host through its UI kit, and understand how an edit reaches the screen.
Terminology
| Term | Meaning | Boundary |
|---|---|---|
| UI artifact | dist/ui/ in a mini app's folder: an entry, hashed chunks, styles, a manifest. | Built by the app from the mini app's ui/ sources. |
| Revision | A hash of every artifact file, carried in the mini app record. | Moves when the UI changed, not when the backend or docs did. |
| UI kit | @localixai/kit/<id>: the host's explicit public component surface. | Public components; SDK services are separate host ports. |
| Shared module | React, @localixai/mini-app-sdk and the kit, taken from the host at runtime. | Every other dependency is bundled into the artifact. |
| Mini app kit | What each release publishes to build mini apps: shared-module exports, theme CSS, validation tools. | The release's mini-app-kit.tar.gz, installed into the mini app. |
What you can do
| Task | Mechanism | Boundary |
|---|---|---|
| Create a mini app | The authoring plugin's create command, with what it should do | The authoring plugin from the Localix marketplace, in Localix or Claude Code. |
| Check, build and install it | mini-app check, then mini-app install | Leftovers, style, builds, types, the mini app's tests. |
| Look at it and use it | The agent's Browser tool with url: "mini-app:<name>" | snapshot, take_screenshot, steps, console; no evaluate. |
| Use a host control | Import it from @localixai/kit/<id> | Typed by its real props; an @/… import fails the build. |
| Draw a graph or a board | Canvas from @localixai/kit/canvas | The host's camera and edge; never bundle @xyflow/react. |
| Take any app link | readMessageBindingDrag, linksInText (kit/context-links) | A drop or a pasted @session:…/localix://… is the same link. |
| Show an app link | ContextBindingPreview from @localixai/mini-app-sdk/ui | The host draws it: a file, a session, a message, a tool call's card. |
| Read and change a list | useCollection from @localixai/mini-app-sdk/ui/app; it hears the agent | add, update and remove answer with the new list, shown at once. |
| Register a screen | screenUi, useAgentContext, useScreenProblem (ui/app) | The tab, what the agent is told, and a failure line. |
| Read or act on any other route | usePolledSnapshot and its send() (ui/route-snapshot) | The escape hatch under useCollection; the route answers. |
| Follow a live stream | createRealtimeFeed, useRealtimeFeed (ui/realtime-feed) | One subscription per key; resubscribes after a reconnect. |
| Complete what is typed | useMiniAppTextInputCompletion + textInputCompletionSurfaces | Surface id <app>.<field>; open tabs are added by the host. |
| Keep what the user creates | defineCollection and createMiniApp(ctx).collection(spec) | Under ctx.dataDir, or per chat with scope: 'chat'. |
| Keep what one chat creates | ctx.sessionStorage.resolveDataDir(userId, sessionId) | Removed with the chat and every session beneath it. |
| Keep tabs in their workspace | The default work-item scope ('session') | Scoped instances stay in their root workspace. |
| Know the tab's own chat | useWorkItemSessionId() from ui/work-item-session | The only session a tab acts for; the SDK has no active session. |
| React to file changes | useMiniAppWorkspaceChanges (ui/runtime-hooks) | One chat, coalesced; poll only when source coverage is unavailable. |
| Use icons | Import from lucide-react | Bundled per icon from the mini app kit. |
| See an edit in the running app | Install again; the mini app is swapped in place | An open tab re-renders; the app is never reloaded. |
Open HTTP and HTTPS links with miniAppExternal.open(url) from
@localixai/mini-app-sdk/ui/runtime: the active conversation's Browser by default, the system
browser with true as the second argument, for flows such as OAuth. Mail and phone links use the
system handler; web links fall back to it when no conversation workspace is ready. System handoffs
retain the host's confirmation policy.
Create a mini app that shows GPU temperature and fan speeds, and warns me above 85 °C
With the authoring plugin installed, the whole text is the request. The agent names the mini app
(lower-kebab-case, such as gpu-monitor) and runs mini-app create, which copies the Daily Log
reference template of the authoring plugin into a new folder — the current project unless another
folder is named — and refuses a folder that already exists. To change a mini app, ask in chat; the
agent checks and installs it again.
The reference copy, Daily Log, builds and passes its tests before its domain is changed. It carries
no copied plumbing: routes, storage, live updates and search are the SDK's
app layer (mini app API version 2). A copy is its entries —
mini-app.json, guide.md, icon.svg, backend/index.ts, ui/register.tsx — and its domain.
For a new copy, mini-app create prints a numbered order of work — read the entries and the
patterns it needs, replace the domain, test, check, install, look, report — followed by the app
layer's calls and the contracts copies have broken before: the tool signature
(execute(_toolCallId, input), executionMode: 'sequential' for a change), state kept in
collections, never in a module variable (register(ctx) runs more than once), notifications through
toast, and the published message context. scaffold.json names the files in the reference that
show each pattern, and the kit's digest lists the core controls with their props.
mini-app check reports template residue first: every Daily Log file and identifier, a missing
test, and an icon.svg still the template's (draw one with mini-app icon; see
the icon standard). A fresh copy fails this step by
design, and mini-app install refuses a copy until every step passes.
The other checks cover the host's design tokens (the same rules the repository guard applies to the
app), palette colours, and native controls such as <button> where the kit has a control. They also
cover the manifest (with Localix's own validation, carried in the kit), routes, store media, backend
and UI builds, types, and the mini app's tests. The report lists each failed step and its problems;
build and type diagnostics include file locations when available. Between checks, the plugin's edit
hook reports the style, route and message-context problems in each file the agent edits.
The type check uses a tsconfig.json the check writes into the mini app, mapping each importable
module to the declarations the mini app kit ships: the SDK and the workspace packages its
declarations import, React, zod, hono, lucide-react, Bun and Node, and the UI kit. The
editor's language server reads the same file, so the diagnostics an agent sees after an edit match
the build. The compiler is TypeScript's JavaScript build, run on the app's Bun in a child process,
so it works on every platform the app ships to.
Kit declarations are generated at staging from the host sources: each component's own props, the element attributes it forwards, and the host types it names. They are then compiled with nothing skipped, and a module that does not compile falls back to its export names.
See and drive a mini app
A mini app's view renders inside the workbench, not in a page with a URL. The Browser tool
addresses it as mini-app:<name>: open records the target, and every command for it is claimed by
the workbench, which opens the mini app's tab (its rail viewlet, or its only work-item kind) and
runs the command against that panel with the same actuator a page probe uses, scoped to the panel.
A session view belongs to the named session's root workspace. A command from the agent targets that workspace's tab; when the person is in another chat, the command is refused with that reason instead of reading the other chat's view, and the agent reads the mini app's state with the app's tools.
| Action | On a mini app view |
|---|---|
snapshot, take_screenshot | The panel's tree and picture, named after the view; the desktop app copies the picture from the screen, so a large view costs no DOM re-render. |
steps (click, type, press, …) | As on a page; selectors and Tab order stay inside the panel. |
console | The mini app's own errors: its uncaught errors and console calls, and render crashes. |
evaluate, viewport | Refused: evaluate would run inside the Localix app, not in a page the mini app owns. |
An open mini app tab's context tells the agent this address, so "take a screenshot of the app" and "click Pods in the cluster app" need no guessing.
How it works
- The authoring plugin builds a mini app against the mini app kit installed in it: the backend into
dist/backend/index-<hash>.mjs(a new file per build; the mini app'sbackendis pointed at it, so a running module is never replaced under the app) withhono,zodand@localixai/mini-app-sdk/backendtaken from the kit, the UI intodist/ui/, and the tests run on Bun. The Localix marketplace builds its own mini apps the same way, in its release workflow; a mini app's artifact keeps its source maps unless its author leaves them out. - The API reads the artifact manifest into the mini app record's
uifield and serves the files under/rpc/mini-apps/<name>/ui/, confined todist/ui/. A mini app is served only once the user enabled it, the same consent as a native backend. - The workbench runtime converges on the capability snapshot. A new revision is imported first, styled, and then swapped: the mini app's old contributions are removed from every registry and the new ones registered. A mini app that left the enabled set is unloaded. An open tab of the mini app re-reads its registration on the swap and remounts under the new one, so it shows the new build at once and a tab that had crashed recovers; other mini apps' tabs keep their state.
- Shared imports read the host's instance. React is available synchronously; SDK and kit modules are awaited. Other permitted imports go through the bundler's resolver.
A build that fails keeps the artifact that last built; the mini app's backend and tools keep
running. The host checks resolved imports against the mini app, the shipped kit, and installed
packages before publishing. Generated output paths and tsconfig.json cannot follow symlinks
outside the mini app. When a UI build succeeds, one previous revision's hashed chunks remain
available for lazy imports already requested by an open tab.
A tab that belongs to one chat
Work items default to session scope; scope: 'global' is the app-level exception. Session tabs are
placed in the root conversation's workspace. Child sessions share that workspace; display scope does
not merge their server-side data ownership.
- Reopening the same scoped instance reveals its tab; distinct instance ids can create multiple tabs. Another root workspace gets its own instance rather than moving the existing tab.
useWorkItemSessionId()reads context derived from the tab's display scope. For a session tab this is the root workspace's session. Use it explicitly in requests; it does not follow the chat on screen or recover a child id from an open payload.- Every open names its chat:
openWorkItem({ …, scope }),openFile({ sessionId, … }),openDiff({ sessionId, … }),openPreview({ sessionId, … }),composer.insert({ sessionId, … })and the terminal calls. The host refuses a session tab that names none. - A tool opens a chat's tab by putting the session in its URI. The anchor that resolves the URI
returns it as
session, and the tab opens in that chat even if the person has switched away. A bare URI opens in the chat the link was followed from. - Deleting the chat closes its tab. The host also removes the mini app's data for that chat
(
ctx.sessionStorage).
scope: 'global' is for an app-level surface that reads no session at all. It is visible in every
chat, useWorkItemSessionId() is null inside it, and it opens with scope: null. A surface that
sends to a chat, opens a chat's files or calls its backend for a chat is a session kind.
Instance ids passed to miniAppWorkspace.openWorkItem must begin with the registered owner's name
and a colon, such as my-app:report-42. The host rejects another namespace. Use the hook's session
context, not the opaque item.scope, for session requests.
That data lives under ctx.sessionStorage.resolveDataDir(userId, sessionId). The routes (boot-time
backend) and each run's tools both carry it, and both resolve the same folder for a session:
- Deletion. Deleting a chat removes its folder for every mini app, and the folder of every session beneath it, whether or not the chat is filed in a project. A startup sweep removes any folder whose session is gone.
- Which session to pass. In a route,
c.env.sessionId; the host sets it only after checking the user owns the session. In a tool,context.session.id. - A session that no longer exists.
resolveDataDirrejects rather than recreating its folder.
The Localix marketplace's board games are built this way: each keeps one board per chat, played against the agent in that chat.
An open board hears the agent's move instead of polling for it. Every openJsonStore write nudges
the views that follow its folder over the realtime socket. A collection wires this for you
(app.registration, useCollection); the lower-level pair is createJsonStoreFeed and
useStoreSnapshot (frame tag c, commands <app>:store.subscribe). The folder is resolved with
the socket's own user, so a view can follow only a chat that user owns. The nudge names the mini app
and the chat, never the state, so the view re-reads through its route.
The view polls at its own cadence until native watcher coverage is confirmed and while the socket is
down. A healthy feed has no periodic read. The shared SDK createStoreChangeFeed covers committed
store writes and external file edits; useStoreSnapshot lives in ui/store-snapshot. After a
reconnect it re-reads once, because a nudge sent while the socket was down is lost.
A live stream of your own
For any other live data, the backend registers a wsFrames entry (a subscribe and an unsubscribe
command, a one-letter frame tag other than s, r and n, and start(params, send, { userId })).
The view follows it with createRealtimeFeed from @localixai/mini-app-sdk/ui/realtime-feed,
created once at module level:
export const boardFeed = createRealtimeFeed<string, BoardEvent>({
tag: 'b',
subscribe: (sessionId) => ({ t: 'board:board.subscribe', sessionId }),
unsubscribe: (sessionId) => ({ t: 'board:board.unsubscribe', sessionId }),
decode: (json) => JSON.parse(json) as BoardEvent,
route: (event) => event.sessionId,
})feed.follow(key, { frame, live, reconnect }) returns the release. In a component,
useRealtimeFeed(feed, key | null, follower) follows while the key is not null and returns
whether the stream is live. The feed handles these for you:
| Concern | What the feed does |
|---|---|
| Shared keys | Surfaces on one key share one subscription; the last release unsubscribes. |
| Routing | One listener parses each frame; route gives a key id, a test, or null for every key. |
| Reconnect | Each key resubscribes and its followers get reconnect: frames in the gap are lost. |
| Live | Set by the subscribe's answer, or only by status frames with status; down on a drop. |
| Late answers | An answer to a subscribe that was since released or replaced changes nothing. |
| Hidden document | With pauseWhileHidden, keys unsubscribe while hidden and resubscribe when shown. |
A follower is told when liveness changes; feed.live(key) gives the state when it joins. Keep a
fallback poll while the feed is unavailable. Keep a healthy-feed timer only for a named source the
subscription cannot cover, such as metrics without a push API.
Files that change under a chat
A view that mirrors the disk — a repository's status, an index of the code — hears changes instead
of polling for them. miniAppFiles.watchWorkspace(sessionId, listener, { windowMs, onLive }), or
the hook useMiniAppWorkspaceChanges(sessionId, onChange, { windowMs }), delivers
{ sessionId, paths } for that chat's workspace:
| Field / answer | Meaning |
|---|---|
paths | Absolute: each changed entry and its directory, .git included (its object store is not watched). |
paths: null | Coarse: too many to list, a watcher restart, or a reconnect that may have missed some. Re-read everything you show. |
| Window | Batches fold for windowMs (500 ms by default) from the first change; steady churn still delivers once per window. |
| Live | onLive / the hook's result. false for a remote session or a failed watcher, null until the host answers. |
Subscribing arms the host's watcher for that chat, so it works whether or not the file tree is open.
Use useFileResourceSync for file-backed lists and editors: it coordinates reads, rejects stale
answers, and polls only while the watcher is unavailable. A sandboxed view (views) never receives
file paths; this door belongs to native workbench UI only.
See Mini app synchronization for source coverage, host-bound scopes, caches, recovery, and draft policies.
Places in the host's own surfaces
These registrations put a mini app inside surfaces the host lays out, while the host names no mini app:
| Registration | What it adds | Boundary |
|---|---|---|
host.toolCards.register({ tool, presentation, Component? }) | The label, noun, verb and icon a mini app tool reads with in the transcript, and optionally its own card | Name a tool your backend registers. First owner of a name wins; disabled → generic. |
host.commentActions.register({ id, label, Icon, run }) | A button beside Send on a comment batch; run receives { sessionId, title, summary, files } | Comments clear only after run resolves. Shown where the batch offers actions. |
host.actions?.register({ id, label, Icon, when, run }) | A contextual content action; receives { sessionId, surface, target } | Optional on older hosts. The mini app decides visibility, persistence and destination. |
host.sessionSurfaces.register({ surface: 'composer.header', ... }) | A strip above the chat's composer, passed { isFocused } as its context | Every warm composer renders it; hold a live subscription only while focused. |
Build a card from @localixai/kit/tool-card (ToolLine, ToolBody, IORow, OutputBlock, …) so
it reads like every built-in card. Label its sections the way they do: In for what the call was
given, Out for what came back and Error for a failure. Pass prose to OutputBlock for natural
language, which wraps at words; code and JSON stay monospace. The file editor's review comments
offer contributed actions. A task-board mini app can contribute its board card in the transcript,
Task on a comment batch, and the chat's open cards above the composer.
The host never imports the destination mini app. ActionTarget distinguishes code, visualizations,
Markdown blocks, tables, images, links and files; surface identifies the host-owned place, such as
chat.content. A synchronous, inexpensive when(context) controls visibility and is checked again
before the host calls run(context). The owning session is supplied with the target; do not
substitute the currently active chat. Contributions disappear when their mini app is disabled or
unloaded. Use ActionSurfaceProvider when composing a new surface; a null surface suppresses
actions during streaming, and board.element distinguishes embedded board content from chat
fragments.
When eligibility depends on live mini app state, provide optional subscribe(context, onChange).
The host calls when again when the mini app signals a change, and releases the subscription when
the fragment is hidden or removed. Keep when free of I/O; share reads and subscriptions inside the
mini app so many fragments do not each fetch the destination. A board mini app can use this seam to
hide an action for content already on its board and restore it after deletion or undo.
host.actions?.register({
id: 'example:save-code',
label: 'Save code',
Icon: Save,
when: ({ surface, target }) => surface === 'chat.content' && target.kind === 'code',
run: async ({ sessionId, target }) => saveCode(sessionId, target),
})A failure, and the program it needs
Report a failure with useProblemReport and render it as ProblemLine or, when the failure is the
whole pane, ProblemState; a missing or stopped external program is a Prerequisite with one
recovery action. Report mini app failures owns the details.
A board
@localixai/kit/canvas is the board every graph in the app is drawn on: a code map and its call
lens, and a database relationship diagram. The host owns the camera, so every board moves the same
way:
| Input | Result |
|---|---|
| Scroll, two fingers | Pan in any direction, tracking the fingers |
| Pinch, ⌘/Ctrl + scroll | Zoom toward the pointer (WebKit pinch included) |
+ / −, the controls | Step through 10–250%; the percentage returns to 100% |
| Shift+1 / Shift+2 / Shift+0 | Fit everything / fit the selection / 100% |
| Arrow keys, Shift + arrow keys | Pan by a small / a large step |
A board chooses interaction: navigate (drag pans, nothing moves) or edit (drag selects,
Space + drag or the middle button pans, nodes move and connect). Wrap the board and whatever drives
its camera in CanvasProvider. useCanvasCamera() moves the camera: fit, focus, focusNode,
zoom. useCameraPlacement(key, place) places it once per content key, so a poll never moves it.
An edge without a type is drawn by CanvasEdge, themed from its data (emphasis, tone,
weight, arrow, label, route). Take Handle and Position from the kit as well: a mini app
that bundles its own @xyflow/react has a second store its nodes cannot reach.
With reduced motion set on the system, every camera move lands at once. Use prefersReducedMotion()
from @localixai/kit/platform for any camera move you animate yourself. captureBoard(frame, rect)
renders one board rectangle as a PNG, whatever the camera shows. keyboardHelp(interaction) gives
the camera's keys, for a shortcuts sheet of your own. An edge's label is marked
data-canvas-edge-label with its data-emphasis, so a board can hide labels when zoomed out and
keep the focused one.
Configuration
| Setting | Where | Effect |
|---|---|---|
ui/register.tsx | Mini app sources | The artifact entry; a mini app without it ships no UI. |
backend/index.ts | Mini app sources | The backend source the app builds. |
backend | mini-app.json | Written by the build: the backend build that runs. |
| Shared-module rule | @localixai/mini-app-sdk/ui-artifact | Which specifiers come from the host. |
| Mini app CSS and utility classes | Imported .css and Tailwind classes | Built into the artifact's stylesheets on the host theme. |
| Script and style sources | tauri.conf.json content security policy | Allows the local API origin for artifacts. |
Limits
Use MarkdownRenderer from @localixai/kit/markdown with source as children; see
Shared Markdown for renderer options and boundaries.
- A user mini app's backend may import only
hono,zod,@localixai/mini-app-sdk/backend,node:*,bun:*and its own files; its UI only the shared modules,zod,lucide-reactand its own files. - A user mini app's tests run with
bun:test, preloaded with the kit'stest-preload(backend/test-preload.mjs), which serves the host's shared modules to them. - A kit type from a package the kit does not ship (Radix, cmdk, Monaco, the flow library behind
@localixai/kit/canvas) is declaredany, and a kit module whose generated declarations do not compile is typed by export name only.noImplicitAnystays off for mini apps. - The
Browsertool cannot reach inside a sandboxed third-party mini app view (an HTML view declared in a manifest); it drives native mini app UI. - A mini app edit remounts that mini app's panels; React Fast Refresh does not preserve state inside a mini app.
- A dependency that
requires a shared module synchronously is supported only for React. - Avoid top-level
awaitin a mini app: it is outside the host's page-lifetime protection. Shared imports use host-controlled awaits whose promises stop settling afterpagehide, to prevent the observed WebKit reload failure. Hiding a workbench tab is not the same as ending the page lifetime.
Related guides
- Build a mini app
- Report mini app failures
- Author a skill
- Keyboard ownership — scoped commands must yield to controls and overlays.
Source of truth
-
The authoring plugin: the UI artifact builder, the style check and the template layout
-
Tab scopes and instance guard.