Web UI
moa serve starts an HTTP/WebSocket server that exposes Moa in the browser.
moa serve # http://127.0.0.1:8080moa serve --host 0.0.0.0 --port 8080 # expose on networkFeatures
Section titled “Features”- 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
Live Preview
Section titled “Live Preview”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.
Opening it
Section titled “Opening it”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.
Looking at the app
Section titled “Looking at the app”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.
Inspect: pointing at an element
Section titled “Inspect: pointing at an element”
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--proselector: .card--pro .btn-primaryattrs: 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.
How the app gets the inspector
Section titled “How the app gets the inspector”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.jsinto 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 Live Preview proxy
Section titled “The Live Preview proxy”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.
moa serve --preview-port 7492 --preview-public-url https://moa.example.test:7492What 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-Optionsand the app’s ownContent-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,RefreshandLinkheaders. - 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
HttpOnlycookie. Publishing the preview port therefore does not publish an unauthenticated way into your private network.
The flags themselves are listed under
moa serve.
Element references and attachment skirts
Section titled “Element references and attachment skirts”
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.
Attachments as the foot of the message: count, total weight, and a fold past four files.
Keyboard shortcuts
Section titled “Keyboard shortcuts”| Shortcut | Action |
|---|---|
⌘K / Ctrl+K | Open session palette |
⌘G / Ctrl+G | Open the pane grid (desktop) |
⌘1..9 / Alt+1..9 | Focus pane by number |
⌘. / Alt+. | Toggle voice input |
Esc | Close 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.
Session palette
Section titled “Session palette”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.
Voice input
Section titled “Voice input”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.
Attachments
Section titled “Attachments”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
documentblock 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; adocumentalready 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.
Files sent by the agent
Section titled “Files sent by the agent”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 (
/tmpis 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.
Limits
Section titled “Limits”- 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_DIRenvironment variable. It defaults to/tmp/moa-<uid>: the directory is created0700and moa refuses one owned by another account, so keying it by user is what lets two accounts on the same machine both stage attachments.
Staging secrets
Section titled “Staging secrets”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.
Queued commands and mid-run messages
Section titled “Queued commands and mid-run messages”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_waitorsubagent_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. Somessage → /compact → messagecompacts 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 (therejectqueue policy); stop the run first./handoffalso requires an empty message queue so its generated brief cannot omit queued context.
- Queued (
- 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/
Escwhile 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.
Skills and /reload
Section titled “Skills and /reload”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_skillcontext: fork # isolated subagent, no inherited messagesbackground: true # with fork: run async, do not block the parentparent-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.
MCP servers
Section titled “MCP servers”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
projectscope is recorded in your project state, not in the repository.
Plan usage
Section titled “Plan usage”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.
Security
Section titled “Security”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.
Attention queue and permission decisions
Section titled “Attention queue and permission decisions”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.
Session WebSocket resume
Section titled “Session WebSocket resume”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 read cursor
Section titled “Attention read cursor”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 isserverInstance: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.
Event inbox
Section titled “Event inbox”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.
REST endpoints
Section titled “REST endpoints”Beyond the per-session WebSocket, Serve exposes a few global read/write endpoints:
| Endpoint | Purpose |
|---|---|
GET /api/version | Current version, update state, and served frontend build id |
GET /api/capabilities | Server/session capabilities (providers, features) |
GET /api/usage | Usage/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-preferences | Read or pin/unpin models in the owner’s global preferences |
GET /api/compact-strategy · PATCH /api/compact-strategy | Read or set what the agent gets before an automatic compaction (plain, notify, prepare) |
GET /api/compact-model · PATCH /api/compact-model | Read 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}/fast | Read 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-models | Known models, and the models subagents are allowed to run under |
GET /api/compact-at · PATCH /api/compact-at | Read or set the default auto-compaction threshold |
GET /api/commands | The built-in slash commands and their arguments |
GET /api/attention | Cross-session snapshot of unresolved attention items |
POST /api/transcribe | Speech-to-text for voice input |
POST /api/sessions/{id}/secrets | Stage a short-lived secret batch; returns its directory and aliases, never values |
GET /api/sessions/{id}/artifacts | List 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}/files | Find files in the session’s working directory for autocomplete |
POST /api/pulse/pairings · .../pairings/claim · GET /api/pulse/devices · POST /api/pulse/devices/{id}/revoke | Pulse pairing and device administration (owner-only) |
GET /api/push/vapid-public-key · POST /api/push/subscribe · .../unsubscribe | Web-push subscription management |
GET /api/preview/target · PUT /api/preview/target | Whether 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/dismiss | The 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.
Frontend development
Section titled “Frontend development”The web UI is a Preact SPA in pkg/serve/frontend/, served at /. Build it,
then override the embedded output for live development:
# 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 binaryMOA_SERVE_STATIC_DIR=pkg/serve/static moa serveThe design lab (token catalog, framed desktop/phone/grid of the real screens) is not in the binary. It is a separate frontend:
cd pkg/serve/frontend && npm run catalog# http://127.0.0.1:7300/?view=desktop — also binds TailscaleA 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.