Skip to content

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 .app can 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

CommandWhat it does
pnpm dev:desktopExpo and Tauri in development
pnpm test:desktopBuild the Expo export and run the Desktop journey headlessly
pnpm test:desktop:headedInteractive diagnosis; only with explicit authorization
pnpm build:desktopUnsigned, unbundled native development executable
pnpm --filter @maiaos/desktop build:appUnsigned local Maia.app for dogfooding (macOS)
pnpm check:desktopConfiguration 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_request admits only the 73 operations whose protocol feature descriptor has desktop: "reviewed-renderer-local-node"; Rust keeps that allowlist with each risk class in src-tauri/src/node_methods.rs, and the registry parity check in pnpm test:tooling fails when it drifts from the descriptors. Rust also enforces the exact SDK envelope, no invocation key 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 consequential or destructive request (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 gets confirmation_declined or confirmation_unavailable and 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 in node_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, no unsafe-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_subscribe relays the Node's observe.events WebSocket, which drives the live Session list; session_attach relays one Session's session.attach item 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_request takes an optional target: local, or remote:<name> for a Node the local Node registered with maia node install (D-088). Rust reads that Node's host, root, profile and launcher from the local Node's node.remote.list, never from the renderer, runs /usr/bin/ssh with the SDK's options (batch mode, a connection of its own, nothing forwarded, SendEnv=-*) and the same bridge command as maia --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 bridge on the host adds that Node's bearer. Failures reach the renderer as node_connecting or node_unreachable with 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 the maia node install command. A Node recorded with maia 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.m runs SFSpeechRecognizer with requiresOnDeviceRecognition; a language without a local model fails as language_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.rs admits dictation_start(locale, events), dictation_finish and dictation_cancel from the trusted main window, one session at a time, locales auto, en-US and fr-FR. Events on the Channel are level (0 to 1, about 15 per second), partial (the whole transcript so far), ended (silence or the 60-second limit), final and error (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:app below). pnpm dev:desktop started 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 say clips 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_PROFILE selects a named profile (default default). A descriptor is accepted only from its own profile's runtime directory.
  • MAIA_DESKTOP_NODE_EXECUTABLE is 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's PATH. For a repository build, point it at a private wrapper such as exec /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

License

No license has been published yet. All rights reserved.

Imported from Maia OS docs at a2175bb