Maia Desktop (@maiaos/desktop)
The Maia OS desktop app: a Tauri v2 shell that hosts the shared Expo client, so you can watch and steer your swarm of AI coding agents (Claude Code and Codex workers) on the same Maia Node state that the CLI uses.
Status: source candidate, macOS first and Linux second. It is not signed or release-qualified. An unsigned local
.appcan be built for dogfooding, and Desktop can start and supervise its own Maia Node.
Desktop is a projection: it renders Node state and submits operations through
@maiaos/sdk and a narrow native bridge; it never owns Projects, Sessions,
credentials or durable state. Use it next to the CLI to see Sessions live, read
results and answer questions.
Run it
You need the repository prerequisites and a Node: either a running one
(pnpm dev:node in a dev worktree; see the
quick start) or a maia-node Desktop may start
(see Node lifecycle). A dev worktree's Node uses the
worktree's own profile, so pass it: MAIA_DESKTOP_PROFILE=<profile>.
pnpm dev:setup
pnpm dev:doctor
pnpm dev:desktop
pnpm dev:desktop starts Expo and Tauri on a loopback port derived from the
worktree path (-- --port <port> overrides it). To enable voice, pass a Vercel
AI Gateway key to the Desktop process only:
AI_GATEWAY_API_KEY='<private Gateway key>' pnpm dev:desktop
Without the key, text works and microphone startup fails closed.
Commands
| Command | What it does |
|---|---|
pnpm dev:desktop | Expo and Tauri in development |
pnpm test:desktop | Build the Expo export and run the Desktop journey headlessly |
pnpm test:desktop:headed | Interactive diagnosis; only with explicit authorization |
pnpm build:desktop | Unsigned, unbundled native development executable |
pnpm --filter @maiaos/desktop build:app | Unsigned local Maia.app for dogfooding (macOS) |
pnpm check:desktop | Configuration and security tests, Rust format, Clippy, tests, build |
Key concepts
- Local content only. The single window loads the repository-built
apps/client/dist; no remote document is configured. A strict content security policy allows only local transports and the exact Vercel AI Gateway WebSocket. - Narrow native bridge. Fourteen Tauri commands (
node_request,node_cancel,node_status,node_lifecycle_status,node_lifecycle_start,observation_subscribe,observation_cancel,session_attach,session_detach,voice_realtime_token,microphone_permission, and the three dictation commands) and no plugins.node_requestadmits only the 73 operations whose protocol feature descriptor hasdesktop: "reviewed-renderer-local-node"; Rust keeps that allowlist with each risk class insrc-tauri/src/node_methods.rs, and the registry parity check inpnpm test:toolingfails when it drifts from the descriptors. Rust also enforces the exact SDK envelope, noinvocationkey anywhere, 512 KiB and 16 levels of nesting, a 30-second deadline and four concurrent requests per Node (sixteen overall); the Node validates the parameters. Every other method fails closed. - Native confirmation. A
consequentialordestructiverequest (approval.respond,connection.system.confirm,interaction.respond,project.create.confirm,session.adopt.confirm,terminal.create,terminal.session.signal,terminal.session.close,voice.managed-turn.confirm,voice.pi.relay) opens a native macOS sheet before dispatch. Remembered approval text and previews are scoped to the exact Node registration; approval text also matches the approval ID and request digest. It shows the Node it goes to, the method, targets, revisions (at most eight of each, then a count), the Node-issued preview expiry when one was seen (matched by the preview's proof or preview ID), for an adoption what the Node previewed (executor and native session, working directory and its origin, Connection, liveness and whether you attested it ended), the command ID, a digest of the exact parameters and the parameters themselves. Renderer text is bounded, and control and format characters and unusual whitespace are shown escaped. Approve has no keyboard shortcut and is disabled for 750 ms after the sheet appears, so only a deliberate click approves; Escape cancels. One sheet is open at a time, an unanswered sheet declines itself after 2 minutes, cancelling the request closes its sheet, and a new sheet waits 2 seconds after any decline. The renderer getsconfirmation_declinedorconfirmation_unavailableand nothing is sent, so it can never confirm its own preview. Linux has no reviewed dialog yet, so these requests fail closed there. - What a compromised renderer can do. By owner decision, sending a message
(
turn.send) and typing into a terminal (terminal.input.write) go straight to the Node, as from the CLI; their risk class stays consequential, and registry parity pins this exact two-method exemption innode_methods.rs(D-076). Starting Sessions is not prompted either. A compromised renderer can therefore drive agents, but only within their executor sandbox and profile (S5, S18). It cannot get an unsandboxed shell without a sheet, because creating a terminal needs one. It can still type into an interactive terminal that already exists, once it takes the controller lease. The compensating controls are the content security policy (script-src 'self', no remote scripts, nounsafe-eval) and agent output rendered as plain text or sanitized markdown with no raw HTML. - Credentials stay native. The Rust process reads the Node's private descriptor and credential and adds the bearer itself; the renderer never sees them. Voice uses a short-lived Gateway client token minted natively.
- Live state.
observation_subscriberelays the Node'sobserve.eventsWebSocket, which drives the live Session list;session_attachrelays one Session'ssession.attachitem stream (at most four at once, view-only) to the Session detail. Each relay checks its bound before it opens the Node socket and again before it subscribes, so a request over the bound sends no subscription. - Remote Nodes.
node_requesttakes an optionaltarget:local, orremote:<name>for a Node the local Node registered withmaia node install(D-088). Rust reads that Node's host, root, profile and launcher from the local Node'snode.remote.list, never from the renderer, runs/usr/bin/sshwith the SDK's options (batch mode, a connection of its own, nothing forwarded,SendEnv=-*) and the same bridge command asmaia --node, and speaks the protocol's bridge frames. The bridge to a Node opens on its first request, must greet as the registered profile within 30 seconds, closes after 5 idle minutes, is not reopened within 5 seconds of a failure and stops when Desktop quits. A consequential request to a remote Node gets the same sheet, which names the Node; after approval the registration must be unchanged. Desktop stores no remote credential:maia-node bridgeon the host adds that Node's bearer. Failures reach the renderer asnode_connectingornode_unreachablewith ssh's last line. The bridge carries no observation stream yet, so the cockpit reloads a remote Node every 15 seconds. Desktop never adds a Node; the cockpit shows themaia node installcommand. A Node recorded withmaia node register(D-176) has no install root, so Desktop refuses it as an invalid record for now.
Dictation
The microphone in the Session composer and the task dock turns speech into
draft text (D-089). Press it, or
mod+shift+D in the field, to start; press it again to stop, or pause for 2
seconds; Escape cancels and keeps the draft as it was. The field shows the
words as you speak and takes no typing until the final text lands at the
caret. Nothing is sent: the text leaves through the dock's own send, exactly
like typed text. The Auto/EN/FR button picks the next session's
language (auto follows the system language).
-
On device only.
native/macos_dictation.mrunsSFSpeechRecognizerwithrequiresOnDeviceRecognition; a language without a local model fails aslanguage_unsupported, never through a server. Microphone buffers go to the recognizer and nowhere else; no audio is written, stored or sent. -
Bridge.
src/dictation_bridge.rsadmitsdictation_start(locale, events),dictation_finishanddictation_cancelfrom the trusted main window, one session at a time, localesauto,en-USandfr-FR. Events on the Channel arelevel(0 to 1, about 15 per second),partial(the whole transcript so far),ended(silenceor the 60-secondlimit),finalanderror(permission_denied,no_microphone,engine_unavailable,language_unsupported); text is cut at 4 KiB. -
Permissions. The first use asks for Speech Recognition and Microphone access. A denial shows a status under the dock and typing keeps working; allow Maia again in System Settings › Privacy & Security.
-
Run it as the app. macOS reads the usage strings from the launching app's Info.plist, so dictation works only from the bundled
Maia.app(build:appbelow).pnpm dev:desktopstarted from a terminal cannot dictate: macOS attributes the request to the terminal. -
Live check. An ignored test builds a small fixture app around the real shim, feeds it two
sayclips and compares them with the spike baseline (first partial about 550 ms, final about 50 ms after the audio, English and French exact). It asks once for Speech Recognition permission:MAIA_LIVE_DICTATION=1 cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml \ dictation_live -- --ignored --nocapture tccutil reset SpeechRecognition dev.maia.desktop.dictation-fixture
Node lifecycle
The renderer mounts its pages only after node_lifecycle_status reports a
ready Node: it calls node_lifecycle_start once when none runs, reads the
status again with a pause that grows to two seconds, and stops after 75
seconds with a "Start Maia" action that runs the sequence again.
At launch, and on node_lifecycle_start, Desktop uses the profile's ready
Node if its descriptor and an authenticated node.status agree (owner: "external"; Desktop never stops it). Otherwise, if
MAIA_DESKTOP_NODE_EXECUTABLE names an absolute executable owned by you (or
root) and writable by nobody else, Desktop runs it as
<executable> --desktop --profile <profile>, waits up to 60 seconds for its
readiness record, checks that the descriptor and node.status report the same
process and instance, and supervises it (owner: "desktop"). On quit it sends
SIGTERM, then SIGKILL after 10 seconds. A failed start is reported as
failed with a reason (spawn_failed, exited_before_ready,
readiness_invalid, readiness_timeout, identity_mismatch,
exited_after_ready) and is not retried within 5 seconds. Without a Node or
an executable the state is unavailable (node_not_running). Desktop never
installs a LaunchAgent or service; that is the CLI's job (RL3).
Desktop holds a private stdin pipe for each Node it starts and sets
MAIA_DESKTOP_PARENT_PIPE=1 on that child only. Closing Desktop, SIGTERM,
SIGINT or a native host crash closes the pipe, including during startup.
The Node interrupts its existing Effect scope to drain workers, the journal
and runtime files. After 10 seconds it kills itself if draining has not
finished. Rust also owns the child from spawn through readiness, stops it
on drop, and fences new starts once shutdown begins. No PID discovered from
a descriptor is ever used to stop an attached Node.
Use a Node build that implements this pipe contract and an exec wrapper
that preserves stdin. Standalone maia-node --desktop does not watch stdin
unless the parent explicitly sets the flag. A renderer-only crash does not
close the native host's pipe. The crash deadline requires a responsive Node
event loop; it is not a guarantee against a frozen Node or operating system.
MAIA_DESKTOP_PROFILEselects a named profile (defaultdefault). A descriptor is accepted only from its own profile's runtime directory.MAIA_DESKTOP_NODE_EXECUTABLEis trusted: Desktop runs it as you, with your environment, so point it only at a Node you built or installed. It must work without your shell'sPATH. For a repository build, point it at a private wrapper such asexec /absolute/path/to/node /absolute/path/to/services/node/dist/bin/maia-node.mjs "$@".
Dogfooding build (unsigned, macOS)
pnpm --filter @maiaos/node build
pnpm --filter @maiaos/desktop build:app
The app is written to
apps/desktop/src-tauri/target/release/bundle/macos/Maia.app. It is unsigned
(ad hoc), has no updater and is for your own Mac only; Gatekeeper may ask you
to open it from Finder's context menu the first time. Apps opened from Finder
do not inherit your shell environment, so either start a Node first
(maia-node --desktop) or set the variables for GUI apps before opening it:
launchctl setenv MAIA_DESKTOP_NODE_EXECUTABLE "$HOME/.local/bin/maia-node"
open apps/desktop/src-tauri/target/release/bundle/macos/Maia.app
Keychain access rebuilt across builds needs a stable signing identity, which this build does not have (planned with RL5).
Status and direction
Implemented as source candidates: the descriptor-derived node_request
bridge, native confirmation, Node detection and supervision, the unsigned local
bundle, the Node status view, the live Session list,
Workspace scope and portfolio reads, managed text requests with explicit
confirmation, question answers, voice through Vercel AI Gateway, and a retained
live-terminal bridge that the current route does not mount, and the Work
cockpit over this computer's Node and every registered remote Node. Planned: signing,
notarization and a signed updater (RL5); a reviewed Linux confirmation dialog; the full-screen cockpit in
the UI specification; live observation of remote
Nodes over the bridge; and a "Create or Pair Node" journey (device pairing
over the tailnet, for mobile first), which is designed in the
client architecture but not
implemented.
Development
pnpm --filter @maiaos/desktop lint
pnpm --filter @maiaos/desktop typecheck
pnpm --filter @maiaos/desktop test
pnpm check:desktop
Linux builds need the webkit2gtk-4.1 and openssl development packages.
Delete apps/desktop/src-tauri/target or apps/client/dist to recover from a
corrupted local build; both are rebuilt per worktree.
Documentation
- Desktop and mobile client architecture
- UI specification
- Shared Expo client and Desktop platform adapters
- Development guide
License
No license has been published yet. All rights reserved.
Imported from Maia OS docs at a2175bb