Skip to content
letmoa.run

Web UI

moa serve starts an HTTP/WebSocket server that exposes Moa in the browser.

Terminal window
moa serve # http://127.0.0.1:8080
moa serve --host 0.0.0.0 --port 8080 # expose on network
  • Multiple concurrent sessions with per-session working directory
  • Session persistence and resume
  • Streaming output over WebSocket
  • Permission prompts and cancel
  • Subagents, MCP (see MCP servers)
  • Model and thinking reconfiguration per session, with pinned-model shortcuts and provider drill-down
  • Per-session fast mode on the models that support it
  • Queue commands and messages while the agent is working (strict send order)
  • Per-session cost readout (main run + subagents)
  • Account plan-usage panel when a supported subscription OAuth login is active
  • Rename (/rename <title>) and delete sessions from the overview
  • Group the mobile drawer and desktop session spine by recency or folder; the choice is saved locally
  • Unread result badges on sessions whose successful run finished while you were away; they are held only in Moa’s process memory and appear first under New results in the mobile drawer. Selecting a session marks the result read after its authoritative transcript snapshot arrives while the tab is in the foreground. When that completed unread result is opened, the transcript starts at the last reply’s reading position rather than jumping to its tail; live sessions continue to follow their newest output. Permission and question badges instead reflect pending session state and disappear when the request is resolved.
  • Live Preview: the web app the agent is building, inside the conversation, with tap-to-inspect feedback
  • Artifacts: reopen files delivered by the agent without searching the conversation
  • Multi-pane tiled layouts
  • Keyboard-first navigation
  • Voice input
  • Stage short-lived secrets for the agent without putting their values in chat
  • Pair a Pulse device by scanning a QR code (or manual code), created from the top bar (POST /api/pulse/pairings)
  • Version indicator in the top bar that links to the latest release when an update is available

When the agent is building a web interface, the thing you actually need to judge its work is the app itself — and it usually runs on the same machine as the agent, on a port you cannot see from a phone. Live Preview puts that development server inside the conversation: the app on one side, the run on the other, and a way to point at a concrete element and say what is wrong with it.

It is aimed at the loop this whole product exists for: the agent changes a screen, you look at the screen, you answer “this button” instead of describing from memory which button you meant.

Live Preview belongs to one conversation, and each conversation remembers its own URL (stored in the browser, per session).

  • Mobile — tap + in the composer and pick Live preview. It takes the whole screen: on a phone the app is the thing you came to look at.
  • Desktop — the preview button in the conversation head. It takes over the conversation area, so the rest of the interface stays put.
  • In a pane grid — each pane has its own preview button and its own preview, so two sessions can watch two different apps side by side.

The first time, the panel asks for the URL of your development server (http://localhost:5173, or just localhost:5173 — a bare host:port typed on a phone keyboard is accepted and prefixed with http://). After that the URL row is gone: changing it is a rare action and lives in the menu of the panel, next to Reload.

The bar above the app carries four viewport widths — 390, 768, 1280 and Fit. The first three render the app at that CSS width and scale it down to whatever room the panel has, which is how you check a phone layout from a desktop pane, or a desktop layout from a phone. Fit simply gives the app the panel’s own size.

On a touch device you can pinch to zoom (up to 4×) and drag to pan; the 1:1 chip resets it. With a mouse, the zoom controls in the corner do the same with buttons, plus arrows to pan. The pinch is measured by an overlay of Moa’s own document rather than inside the app, because a gesture that crosses an iframe boundary is split between two documents and never becomes one gesture.

While the preview is open it covers the transcript, so the run is shown as a stream: each thing the agent does — a tool call in the ledger’s own grammar (“Editing style.css”), or a run of its prose — floats up from the bottom over the app and dissolves. Idle, nothing at all is drawn over the app. One card does not expire: a run parked on a question or a permission stays until you answer it. Tapping a prose card opens the full message; Go to chat closes the preview and returns to the transcript.

Live Preview on a phone with Inspect on: a button in the previewed app is highlighted and a popover asks what should change
Inspect mode: tap an element in your app and write (or dictate) what should change.

Inspect is the reason the panel exists. Turn it on in the bar and tap any element of your app: it is outlined, and a popover opens next to it with the element’s short selector, its visible text, and a box for your comment. You can type it, or hold the microphone and dictate it — the same voice input the composer uses (hold to talk, slide up to lock).

Send posts an ordinary user message into the conversation. The agent receives your comment plus an unambiguous handle on the element:

Give this action a bit more presence.
[UI feedback · selected element in preview]
page: /pricing (http://localhost:5173/pricing)
element: button#buy.btn.btn-primary — text: "Buy"
ancestors: main.pricing > section.pricing-grid > article.card.card--pro
selector: .card--pro .btn-primary
attrs: type="button" data-plan="pro"

That block is what the model needs — the page, the tag, id and classes, the containing elements and a CSS path — not what you should have to read. In the transcript the message paints as a short reference instead; see Element references and attachment skirts.

Inspect works through a small script that runs inside your app and talks to Moa by postMessage only. There are two ways to get it there:

  • Let the proxy inject it (recommended) — Moa serves your dev server through its own listener with the script already in the page, so you change nothing in your project. Nothing to enable up front: the listener is started the first time you load a URL in the panel (see below).
  • Add it yourself — if you do not use the proxy, the panel loads your dev server URL directly in the iframe, so the script has to be part of your app. Copy pkg/serve/frontend/src/components/LivePreview/inspector.js into it and load it with <script src="/inspector.js" data-moa-origin="<your moa origin>">.

Without the script the preview still shows and reloads your app, but a notice appears: tap-to-inspect and touch gestures are unavailable until it is there.

The proxy exists so Inspect works on an app you did not modify, and so the preview can reach a dev server that your browser cannot see directly.

It starts and stops while Moa runs. There is nothing to enable at startup and no restart to pay: the listener is opened the moment you load a URL in the preview panel, and closed again when you leave it. With no preview open Moa holds no extra port at all. This matters because moa serve is long-lived — restarting it to turn a preview on would cut every session running under it.

The one thing Moa cannot work out on its own is the address your browser uses to reach that listener, because it depends on how you expose the port (a tailnet, a reverse proxy, a LAN address). So the first time it is needed the panel proposes one, built from the address you are already on — reaching Moa at https://dev.example.ts.net:7401 proposes https://dev.example.ts.net:7402 — and you confirm it or correct the port. The default is Moa’s own port plus one, so two Moa instances never propose the same listener. The address is then remembered in your global config (preview in Configuration) and never asked again; whether the proxy is running is not remembered, since that is decided per use.

If the port is busy, activation fails and says so — Moa never reports a live preview it did not bind. If the address is wrong, the frame will not load: the error offers to change the address on the spot.

The listener binds 127.0.0.1. Moa is transport-agnostic and never manages tunnels: expose that port however you already expose Moa itself. The address you confirm must be the one browsers actually use, because the proxy rewrites your dev server’s own origin to it, which is what keeps localhost:5173 links, HMR websockets and redirects inside the app working.

moa serve --preview-port / --preview-public-url still work: they are initial configuration, so the panel never has to ask. They do not open the port either — that still happens on first use.

Terminal window
moa serve --preview-port 7492 --preview-public-url https://moa.example.test:7492

What the proxy does to the traffic:

  • Injects <script src="/__moa/inspector.js"> into HTML responses, right after <head>, unless the page already carries it.
  • Strips X-Frame-Options and the app’s own Content-Security-Policy (header and <meta>) so the page can be framed, and replaces it with one allowing only Moa’s origin as a frame ancestor.
  • Rewrites the dev server’s origin to the public URL in HTML, CSS, JavaScript and JSON bodies up to 8 MB, and in Location, Refresh and Link headers.
  • Namespaces the previewed app’s cookies per target and clears them when you switch target, so two apps previewed in turn never see each other’s state.
  • Refuses a redirect that leaves the validated target.

Only one target is active at a time. Changing the URL in the panel repoints the proxy, closes the previous target’s connections and issues fresh credentials. Closing the preview goes further: the listener itself is torn down along with every connection through it, including requests still in flight and HMR websockets, and the credentials issued for that session stop working — turning the preview on again mints new ones.

Trust model — read this before pointing it anywhere. The previewed app is served from the proxy’s origin, so it runs with whatever that origin is allowed to do in your browser. Live Preview is for your own development servers, on machines you control. Do not point it at a third-party site or an app you do not trust.

Moa enforces the boundary it can:

  • A target must resolve to a loopback, private or tailnet (CGNAT) address. Public addresses, link-local, unspecified and IPv4-mapped IPv6 targets are refused, as is the Tailscale metadata service.
  • A target may not point back at Moa itself or at the preview listener.
  • Validated addresses are pinned at dial time, so a hostname cannot be re-resolved to a different address between the check and the connection.
  • Every request through the listener — documents, assets, websocket upgrades — needs a capability issued only by Moa’s owner-authenticated API, exchanged once for an HttpOnly cookie. Publishing the preview port therefore does not publish an unauthenticated way into your private network.

The flags themselves are listed under moa serve.

Two messages in a transcript, each with a one-line reference to the element selected in the preview hanging off the message spine
What you pointed at, read back: the element's own words, the containing card, and the page.

A message sent from the preview carries a technical block the agent needs and you do not. In the transcript that block is not printed as text: it is read back and painted as a reference hanging off the message’s own spine — the element’s visible text (or its accessible name behind a tag mark, for an image or an icon button), one disambiguator such as the containing card, and the page. Tapping the reference opens the full selector, ancestors and attributes. What the agent receives is unchanged, and messages already in your history paint the same way.

Attachments on your own messages are grouped into a skirt: one box at the foot of the message, with a header carrying the file count and the total weight and one row per file. A single attachment needs no header. Past four, three rows are shown and the rest fold behind N more, so a message keeps a predictable height however much was sent. Image rows still open the lightbox, other rows still download, and a file this device cannot reach stays as a non-interactive row.

Two messages with attachment skirts: one listing three files, one folding five of eight behind a 'more' row
Attachments as the foot of the message: count, total weight, and a fold past four files.

ShortcutAction
⌘K / Ctrl+KOpen session palette
⌘G / Ctrl+GOpen the pane grid (desktop)
⌘1..9 / Alt+1..9Focus pane by number
⌘. / Alt+.Toggle voice input
EscClose palette / go back
[ / ]Cycle sibling subagents while viewing one

On non-Mac platforms the labels show Alt instead of ; the palette and grid chords also accept Ctrl.

The session palette (⌘K) lets you search sessions, jump to open ones, resume saved sessions, or create new ones with a chosen project path.

On desktop, you can split panes horizontally or vertically, switch focus by keyboard, and apply layout presets from the top bar.

Requires moa --login openai-transcribe. Browser microphone access usually needs HTTPS, so it works best on localhost, Tailscale, or behind your own HTTPS setup.

Hold the send button to record, release to transcribe, or slide up while holding to keep recording hands-free. This also works when text or attachments are already present: a short tap sends them, while a hold appends the transcript at the cursor. ⌘. / Alt+. also starts and stops a hands-free recording.

Agent questions (ask_user) take dictation the same way: hold their answer button, or use the shortcut while the question is on screen. Speech is appended to whatever the answer already contains, so a long reply can be dictated in several passes; dictating over a chosen option replaces it.

If it keeps mangling a name or a piece of jargon, add that word to stt_vocabulary. The model and the language hint are configurable there too.

The composer accepts file attachments (paperclip icon, drag-and-drop, or paste). How each file is handled depends on its type:

  • Images (jpeg/png/gif/webp) are sent to the model natively as vision input. Large photos are downscaled in the browser before upload. The server validates the file’s magic bytes against the declared type — a binary mislabeled as an image is saved to disk instead of being forwarded to the provider.
  • PDFs are sent to the model natively as a document block when the active provider supports it AND the bytes are actually a PDF (%PDF- magic; Anthropic always supports documents, OpenAI on the API-key path). xAI Grok currently supports images but not native document/PDF input in Moa. If the active provider does not support native documents — the PDF exceeds the size limit, or the content isn’t a real PDF — it is saved to disk as a fallback (see below) and the agent is told where to find it. Because Moa is provider-agnostic and you can switch models mid-conversation, this decision is made per message against whichever provider is active at send time; a document already in the history is degraded to a text note if you later switch to a provider that can’t accept it.
  • Small UTF-8 text (≤256 KiB: .txt/.md/.csv/.json, source code, etc.) is inlined directly into the message, wrapped in an <attachment> marker.
  • Everything else (.xlsx/.docx/.zip, binaries, and text larger than 256 KiB) is saved to disk under /tmp/moa-<uid>/<session-id>/, and that directory is added to the session’s path allowlist so the agent can process the file with its own tools (bash, read, etc.). Moa itself does not parse Office/archive formats — the agent decides how, on demand.

In the conversation history, images render as thumbnails you can open full-size, and every other attachment renders as a chip that downloads the stored file.

Ask the agent to send a file, then open its card or choose Artifacts from the conversation’s actions to find it again. Every file delivered through send_file appears in that conversation’s collection; creating or editing a file alone does not add it. Search the collection by title or filename.

On desktop, the reader opens on the right and can expand to full screen. Each grid pane has its own Artifacts entry, but all panes use the same drawer: opening another pane’s collection switches its contents; merely changing pane focus does not. On mobile, tap + in the composer and pick Artifacts; the reader fills the screen. Use Back to return and Share to download the file or open the OS share sheet where supported.

Markdown, text and images can be read in the viewer. HTML supports interactive reports inside a sandboxed iframe, without access to Moa’s DOM, cookies or storage. External HTTPS resources may load automatically; the resource inspector lets you check their domains. Keep local HTML assets inline or use externally hosted assets: publishing an HTML file does not publish the rest of its directory. Other formats, and files too large to preview, remain downloadable.

Keep files you want to revisit in a stable location. Artifacts retain a reference to the original file, not a copy or a version:

  • References survive server restarts and closing or reopening a conversation.
  • Opening or sharing reads the file’s current content, including edits made by another conversation. Sending the same canonical path again updates its entry instead of adding a duplicate.
  • Deleting a conversation removes its artifact references, not the original files in your project.
  • If the source is moved, deleted or cleaned from temporary storage, restore it at the same location and retry, or ask the agent to send its new location.
  • Older download links whose in-memory registrations have already been lost are not recovered automatically.

The send_file tool accepts optional title and description fields for the card and collection. Existing path and name arguments continue to work.

Uploaded files saved to disk are ephemeral

Section titled “Uploaded files saved to disk are ephemeral”
  • They live under /tmp/moa-<uid>/<session-id>/ and are deleted when you delete the session.
  • They may disappear if the server restarts (/tmp is not durable). Resuming an old session does not restore them.
  • “Attaching” a spreadsheet does not mean the model has read it — the agent must open it explicitly (e.g. via bash). Attached files are untrusted user data; the agent is told to treat them with care.
  • Up to 8 attachments per message.
  • 32 MB per file; 64 MB decoded total per message; 200 MB on-disk per session.
  • Native binary content (images, plus any natively-forwarded documents) is additionally capped at 48 MB cumulative across the session’s history (maxSessionNativeDocBytes), because native blocks are re-sent to the model every turn; individual images are capped at 5 MB decoded. Content beyond the cumulative budget falls back to disk instead.
  • Files that exceed the client-side cap are rejected before upload. Raising these limits would require changing the transport (currently base64-in-JSON), which is out of scope.
  • The base directory can be overridden with the MOA_ATTACHMENTS_DIR environment variable. It defaults to /tmp/moa-<uid>: the directory is created 0700 and moa refuses one owned by another account, so keying it by user is what lets two accounts on the same machine both stage attachments.

An owner-authorized client can send a short-lived credential batch to POST /api/sessions/{id}/secrets without putting the values in the chat:

{
"secrets": [
{"name": "db-produccion", "value": "…"},
{"name": "netrc", "value": "…"}
]
}

Moa writes one 0600 file per alias in a fresh 0700 directory below the system temporary directory, then tells the agent only that directory and the aliases. The agent installs each credential where its client expects it and deletes the files afterwards. Batches are also removed on session close/delete or periodically once they are at least six hours old. The response contains only directory and aliases.

Important boundary: this is not a vault and does not protect a secret from the agent. The agent’s shell runs as the same Unix user and can read the staged files. If it reads or prints a value, that content enters the model context and transcript like any other tool output. Only use it with repositories and commands you trust.

What staging does provide is narrower: the value never passes through chat input, is never persisted in your message, and Moa itself never sends it to the model — only the directory path and aliases.

In the web UI, use /secret alias1 alias2 to enter a masked value for each alias, or /secret to add aliases one at a time. Type only names, never values, after /secret: values are deliberately not accepted on that command line, and the command takes at most three aliases so a pasted value is less likely to be read as a name — add further names in the masked form. Moa refuses and discards every recognized /secret command before it can enter its composer history, local draft, dispatch, or transcript. Your browser, terminal, keyboard, or OS may still retain what you typed, so if you accidentally type a value there, rotate that credential.

You don’t have to wait for a run to finish before lining up your next move. What you type while the agent is working is handled in strict send order — the order you sent things is the order the agent sees them.

  • Messages typed mid-run are steered onto a queue and delivered to the agent between steps of the current run (or, if they arrive after the run ends, they start the next one). If the agent is blocked in bash_wait or subagent_wait, a message wakes that wait immediately while its background job continues. They show up as a queued chip above the composer.
  • Slash commands typed mid-run are classified by what they do:
    • Queued (/compact, /prepare-compact, /clear, /model, /thinking, /verify, /reload, /goal <objective>) — these rewrite or reconfigure the conversation, so they can’t run in the middle of a live turn. They wait in the queue as a command chip and run at the next idle point, in order relative to your messages. So message → /compact → message compacts after the first message lands and before the second.
    • Instant (/rename, /permissions, /path, /tasks, /schedule, /goal status, /goal stop) — these only touch side state, so they run immediately without waiting.
    • Rejected (/handoff, /undo, /branch, /back, /plan) — these only make sense against a settled conversation and are rejected while the agent is working (the reject queue policy); stop the run first. /handoff also requires an empty message queue so its generated brief cannot omit queued context.
  • Attachments can be added to a mid-run message too (the paperclip is no longer disabled while a run is in flight); the image/file rides along with the steered message.
  • Editing the queue: click the queued chip (or Alt+↑) to pull everything back into the composer for editing — this cancels the not-yet-delivered items so you don’t get both the originals and your edit. Queued images can’t be pulled back (only their count is tracked client-side), so re-attach them if needed.
  • Stopping: pressing Stop/Esc while a run is in flight dumps whatever was queued back into the composer, so nothing you lined up is silently lost.

/clear while a run is queued behind it starts a fresh conversation but keeps the items queued after it — they belong to the new conversation.

A skill is a folder with a SKILL.md in .moa/skills/<name>/ (project) or ~/.config/moa/skills/<name>/ (global). Its first heading is the title, and a one-line description follows it.

Global skill folders may be symbolic links. This lets ~/.config/moa/skills/ act as an activation directory while personal and third-party skills remain in separate repositories. The link name is the installed skill name. Symbolic-link activation is supported only in the global skills directory; entries directly under a project’s .moa/skills/ must be ordinary directories. Unlike AGENTS.md, project skills are read only from the session’s exact CWD; Moa does not search parent directories for .moa/skills/.

By default a skill is listed in the system prompt so the agent can pull it in with load_skill, and you can invoke it yourself by typing /<name>. Arguments land wherever you write $ARGUMENTS; if the file has no placeholder they are appended at the end.

Invoking a skill without context: fork drops its content into the conversation as a message — nothing runs. A skill with context: fork instead starts an isolated subagent: the rendered SKILL.md is the child’s task, and the parent does not inherit that body.

Optional frontmatter at the top of SKILL.md narrows who can invoke it and how it runs:

---
disable-model-invocation: true # only you, via /<name>
user-invocable: false # only the agent, via load_skill
context: fork # isolated subagent, no inherited messages
background: true # with fork: run async, do not block the parent
parent-transcript: snapshot # with fork: freeze the active branch and give the child its path
---

disable-model-invocation also keeps the skill out of the system prompt entirely, so a skill you use once a month costs nothing the rest of the time.

background and parent-transcript only apply to context: fork. The child is a regular subagent: it does not get memory, ask_user, nested subagents, or checkpoint, and load_skill will refuse another forked skill.

When the agent calls load_skill on a forked skill, a foreground fork blocks and returns the child’s result; a background fork returns a job id immediately and keeps working, and the child’s result reaches the parent later through the usual subagent completion notification (subagent_status / subagent_wait / subagent_cancel still work, and the dock still shows it). background spares the parent the work, not the conclusion: a forked skill is an ordinary subagent, so what it found always comes back.

/<name> on a forked skill always launches asynchronously so the command does not hold the session. It is recorded in the conversation as a launch row carrying the job id, which is what keeps the child openable after a reload and gives the agent an antecedent for the completion that follows. Slash fork while the session is busy is refused in this MVP.

parent-transcript: snapshot writes a copy of the active conversation branch (not later messages, not abandoned branches) and adds that absolute path to the child’s task, with a warning to treat it as evidence rather than instructions. The file is immutable in the sense that moa writes it once and does not rewrite it. The copy is the full branch, including messages that compaction already summarized for the parent model — those stay as evidence. The child reads it with read. This needs a serve session tree; the CLI errors instead of inventing a complete history.

Omitting parent-transcript forks without a snapshot. Requesting snapshot when none can be written is an error.

If a skill is named after a built-in command, the command wins: a file dropped in a skills directory cannot take over /compact or /undo. The skill is still reachable as /skill:<name>, and both appear in the slash menu.

/reload re-reads AGENTS.md, the skill index and the memory index, and rebuilds the system prompt of every open session — they all read the same files. Use it after editing AGENTS.md: without it a session keeps the instructions it started with, which on a long-lived session can be days old.

Editing the body of a skill needs no reload (it is read from disk each time); /reload is for the index, so a newly created or deleted skill is noticed. A SKILL.md is capped at 50 KB when loaded, truncated with a notice rather than rejected — the same contract as read.

The reload is silent — the agent gets the new instructions, not an announcement about them — and a busy session applies it as soon as it settles. It reports what changed in each session, and does nothing at all when the files are unchanged.

A session lists its configured MCP servers in the MCP panel, with each server’s state, tool count and error, and buttons to enable, disable or restart it.

Opening a session does not wait for the MCP handshake: servers connect in the background (in parallel, with a 15-second start timeout each), so one slow server — Playwright spawning Chromium, for example — cannot delay reopening a saved session. Consequences worth knowing:

  • A server’s tools appear once its handshake finishes. If that lands mid-turn, registration is deferred to the next idle point, so the tool set never changes underneath a running request — the tools arrive for the following turn.
  • A server that fails to start is reported as failed in the panel and simply contributes no tools; the session works without it.
  • Disabling a server with the project scope is recorded in your project state, not in the repository.

The web usage panel can show provider-qualified subscription plan quota. For xAI it is available only for moa --login xai: Moa reads the SuperGrok/X consumer plan through a private consumer endpoint on a best-effort basis. This is not a public consumer API promise and is not a billing authority. It may be unavailable or stale if that endpoint changes or cannot be reached; failure to read it never blocks a Grok request.

Meta plan usage is not shown at all: the only known subscription snapshot rides the Muse key-mint response, so the panel states the credential kind instead of polling.

XAI_API_KEY uses the separate, metered api.x.ai developer API, so its plan usage is intentionally not shown in this panel. Moa does not publish xAI pricing or calculate xAI run cost until that information is verified.

By default moa serve has no authentication — anyone who can reach the port controls your agents. For access beyond 127.0.0.1, pass --token <secret> (or set MOA_SERVE_TOKEN) to require a session cookie or ?token=<secret> on every request; visiting that URL once sets an HttpOnly cookie for subsequent requests. The owner boundary is therefore either that token, when configured, or the operator-selected network boundary (localhost/Tailscale) when it is not. That owner can pair a revocable Pulse device. A claimed device authenticates REST and WebSocket requests with Authorization: Moa-Device <device-id>.<secret>; its credential is separate from the owner token and is rejected outside direct loopback unless the request uses TLS. Pairing and device credentials are not accepted in URLs.

When a normal OpenAI API key is configured for auxiliary features (the openai-transcribe credential, set with moa --login openai-transcribe, or a plain OPENAI_API_KEY / API-key openai credential — never OpenAI OAuth), a paired device may call POST /api/pulse/realtime/client-secret with exactly {} to receive a Realtime client secret requested for 60 seconds (Moa accepts at most an additional 5 seconds for OpenAI clock/transport skew). This is a device-only route: owner cookies and tokens cannot mint it. Moa sends only the server-controlled gpt-realtime-2.1 Realtime configuration to OpenAI and returns a minimal credential DTO; Pulse then talks directly to OpenAI. Moa does not proxy, store, or log audio, SDP, conversation data, the client secret, or the permanent API key. Revocation prevents a subsequent mint from being delivered once it wins the device lifecycle boundary; it cannot recall a client secret already delivered, which may remain usable until its OpenAI expiry. The route has the same Host, CSRF, TLS/loopback, revocation, concurrency, and rate-limit protections as other paired-device operations.

An emparejado Pulse device represents the owner on Serve’s generic API: it can read sessions, conversations and activity and use the same generic actions as the web client. This is deliberate: Pulse is a client of Moa, not a separate restricted product surface. The exceptions are pairing administration: only the network/token owner can create pairings, list paired devices or revoke a device. An already paired device cannot extend its own authority.

Inbound automation (webhooks, cron, CI) uses a separate shared secret, --automation-token / MOA_AUTOMATION_TOKEN, presented as Authorization: Bearer <secret>. It only opens POST /api/automation/runs; neither the owner token nor a paired device can call that route, and the automation token grants nothing else. Without the token configured the automation routes do not exist (404), even on localhost. See Automation API.

GET /api/attention returns an informational, cross-session snapshot of unresolved attention items. It describes what needs the owner’s attention; it does not define an approval or echo-confirmation protocol. For a permission item, an owner-authorized client uses its session_id and ref_id with the existing generic POST /api/sessions/{id}/permission action to decide it.

Permission items retain risk_level, risk_flags, and verbatim so a client can present or read the assessed risk and exact command before making that generic decision. They are information for the client and owner, not a server-enforced confirmation ceremony. The attention item intentionally no longer includes requires_verbatim_confirm; clients must not infer an echo-confirmation requirement from the queue. Serve has no formal API version; this is the current attention contract.

Moa also rejects requests whose Host header isn’t localhost, an IP literal, or an explicit --allowed-hosts entry (anti DNS-rebinding), and requires an X-Moa-Request header on non-GET requests (CSRF protection). None of this replaces a real network boundary: prefer localhost, Tailscale, or a reverse proxy for remote access, and use --token on top of it. When pairing remotely, terminate TLS at Serve or a trusted proxy; Tailscale connectivity alone does not make an HTTP request TLS to Serve.

xAI login and plan-usage support do not change this security model or expose a new Serve authentication route.

GET /api/sessions/{id}/ws sends an init event before live events. A client with a retained transcript may pass since_msg=<durable message ID> to request a smaller init: if that entry is still on the session’s current tree path, the init includes delta_base with that ID and messages contains only the suffix after it. Clients append that suffix only when they still have the named base.

The server falls back to the ordinary full, bounded history snapshot when the token is absent, stale, from another branch, removed by /clear, or when the suffix itself cannot fit the reconnect history limit. A resume token is an optimization, not an event replay cursor: clients must continue using the normal server_instance, last_seq, and attention_namespace semantics from every init. Unknown query parameters are ignored, so clients and servers can be upgraded independently.

Attention is tracked per session as a read cursor: you have seen everything through bus sequence N. An attention-producing event whose sequence is above that cursor keeps the session marked unread. A confirmed WebSocket init for a selected session acknowledges its last_seq while the tab is in the foreground; a rendered live attention event acknowledges its own sequence. A hidden tab never acknowledges attention.

POST /api/sessions/{id}/read advances that cursor. It takes these query parameters:

  • through_seq: the bus sequence through which the client has rendered.
  • attention_namespace: the runtime incarnation that owns that sequence. It is serverInstance:incarnation, where the incarnation increases when a session runtime is recreated.

The endpoint returns 204 No Content on success, 400 Bad Request for an invalid or future cursor, 404 Not Found for an unknown session, and 409 Conflict when the namespace has been superseded. A client must obtain both values from the current runtime rather than carrying a cursor across a session resume.

Every WebSocket init includes last_seq and attention_namespace. last_seq is a conservative acknowledgement boundary, not an atomic snapshot of every field in the init: all attention effects at or below it are represented or superseded by the snapshot. attention_namespace identifies the ordered runtime incarnation for its bus sequence, preventing an old socket’s sequence from being interpreted against a recreated runtime.

The GET /api/sessions roster includes unseen, unseen_seq, and attention_namespace. unseen is the current attention badge, unseen_seq is the highest unread attention occurrence, and attention_namespace scopes that occurrence and any read cursor to its runtime incarnation.

External events — a mail reply, a failed pipeline, a cron job — reach moa through POST /hooks/<source>/<secret> and are addressed to a project, a session, or the inbox, according to that source’s config.

By default an event is sent straight to a live session in the target project, so the work continues without you opening moa. When routing cannot pick one session (none, several, a missing/errored target, a busy session with autorun off, or a rate-limited source), the event waits in the Inbox — its own surface, not a group inside the session list. Its door is the inbox button in the session list (the spine’s header on desktop, the session drawer on mobile); on a phone the count also rides on the title chip, so waiting events are visible without opening the drawer. Each waiting row says why it is there, and opens its payload on the same code surface the transcript uses, so you can read what actually arrived before deciding:

  • Send to ‹session› — inject it into a live session of that project. The sessions offered are exactly the ones the server will accept.
  • New session — open a session in that project and inject it there, using the source’s configured model and thinking unless you override it.
  • Dismiss — drop it. Nothing is sent anywhere.

Choosing a destination starts a turn on it: placing an event by hand is the instruction to act on it, whatever the source’s unattended autorun setting says. A route the server refuses leaves the event pending with an error instead of silently closing the inbox.

The inbox keeps history: pending, delivered, and dismissed. A dismissal survives a restart (~/.config/moa/events.json).

A push notification announces each arriving event, and opens the inbox rather than the home screen. Following the push contract it carries only what happened and, at most, the session title — never the event’s own text, which is external content that would land on a lock screen.

In a conversation, an event is a monochrome timestamped mark with its payload on the tool code surface: it neither impersonates you nor competes with the assistant. A session that an event created is marked as such in the session list.

Beyond the per-session WebSocket, Serve exposes a few global read/write endpoints:

EndpointPurpose
GET /api/versionCurrent version, update state, and served frontend build id
GET /api/capabilitiesServer/session capabilities (providers, features)
GET /api/usageUsage/cost readout
GET /api/sessions/{id}/history?before={msg_id}&limit={n}Chronological, lossless display-history page before a message ID; the page size is an objective and can grow to keep tool calls with their results
GET /api/model-preferences · PATCH /api/model-preferencesRead or pin/unpin models in the owner’s global preferences
GET /api/compact-strategy · PATCH /api/compact-strategyRead or set what the agent gets before an automatic compaction (plain, notify, prepare)
GET /api/compact-model · PATCH /api/compact-modelRead or set the model that writes compaction summaries (session, or a model spec). Only models whose provider has a usable credential are offered, and an unknown spec is rejected on the spot
GET /api/sessions/{id}/fast · PATCH /api/sessions/{id}/fastRead or set fast mode for one session; the reply also says whether the current model can serve it (supported) and what it costs there (note). Allowed while the agent is running
GET /api/models · GET /api/subagent-models · PATCH /api/subagent-modelsKnown models, and the models subagents are allowed to run under
GET /api/compact-at · PATCH /api/compact-atRead or set the default auto-compaction threshold
GET /api/commandsThe built-in slash commands and their arguments
GET /api/attentionCross-session snapshot of unresolved attention items
POST /api/transcribeSpeech-to-text for voice input
POST /api/sessions/{id}/secretsStage a short-lived secret batch; returns its directory and aliases, never values
GET /api/sessions/{id}/artifactsList the conversation’s published artifacts, including saved conversations
GET /api/sessions/{id}/files/{fileID}Read or download a published artifact’s current file contents
GET /api/sessions/{id}/filesFind files in the session’s working directory for autocomplete
POST /api/pulse/pairings · .../pairings/claim · GET /api/pulse/devices · POST /api/pulse/devices/{id}/revokePulse pairing and device administration (owner-only)
GET /api/push/vapid-public-key · POST /api/push/subscribe · .../unsubscribeWeb-push subscription management
GET /api/preview/target · PUT /api/preview/targetWhether the Live Preview proxy is listening right now, at what address, and which dev server it points at. PUT with a url starts the listener and points it; PUT {"enabled": false} shuts it down
GET /api/events · POST /api/events/{id}/route · .../dismiss · POST /api/events/dismissThe event inbox: history, sending or dropping one, or dismissing a source

The web transcript initially opens on the recent conversation and automatically loads older history as you scroll upwards. Each older page is prepended while preserving the visible reading position.

The web UI is a Preact SPA in pkg/serve/frontend/, served at /. Build it, then override the embedded output for live development:

Terminal window
# build the SPA into pkg/serve/static (embedded at compile time)
cd pkg/serve/frontend && node esbuild.mjs --prune # or: bun esbuild.mjs --prune
# serve that build directory without recompiling the binary
MOA_SERVE_STATIC_DIR=pkg/serve/static moa serve

The design lab (token catalog, framed desktop/phone/grid of the real screens) is not in the binary. It is a separate frontend:

Terminal window
cd pkg/serve/frontend && npm run catalog
# http://127.0.0.1:7300/?view=desktop — also binds Tailscale

A change to ChatHead or the status strip shows up there on reload because the lab mounts those components, not a copy. It does not talk to a running moa; the conversation it shows is a frozen specimen.

The build output is one tree: the app bundle plus the assets the PWA references absolutely at the root — the service worker (/sw.js, which push runs through), the icons and the manifest.

The build stamps that runtime tree with one content-derived id, embeds it in the JavaScript bundle, and publishes the shell, JavaScript and CSS together under /build/<id>/. A running web app compares its id with /api/version on load and when it returns to the foreground; if the server has replaced the frontend, it navigates to the new versioned shell without installing or restarting Moa. Embedded assets use content ETags and revalidate before reuse. The mutable MOA_SERVE_STATIC_DIR tree instead uses no-store, and its build id updates without restarting the development server.