The web surface
mecha serve is mecha on your phone: a small web app, served by the same
process that holds the agent, reachable from anywhere your tailnet reaches
and from nowhere else.
It exists because the terminal is where mecha lives and the terminal is not where you are. A draft that needs approving, a thread that needs reading, a queue that needs clearing — none of that wants a laptop, and all of it was previously stuck behind one. It is also the only way in to voice: a call is opened from the chat view here and speaks into the conversation already on screen.
Try it
The frame below is the app — the real bundle from web/, built by the same
Vite config, with the same components and the same stylesheet. What differs is
only what comes back over /api: fixtures instead of a box.
There is no public mecha to link to, and there never will be — the server binds
127.0.0.1 and refuses any request without your tailnet identity. A screenshot
of the real one would be a picture of somebody's actual mail, calendar and
knowledge graph, on a repository that is public. So the docs build compiles the
app against invented fixtures (web/src/demo/) and embeds that. Every name in
it is fiction and the reply is scripted; everything you can see and click is
the shipped app.
The door
The door checks both identity and request intent:
- The bind is
127.0.0.1, and there is no flag to widen it. Reaching the app from your phone istailscale serve's job. Reaching it from the internet is nobody's. - Identity is the network, verified. Every request must carry a
Tailscale-User-Loginheader equal to[web] owner_login, which is the headertailscale serveinjects for the authenticated tailnet user. Missing header, wrong value, or unset config all fail closed — and the server refuses to start with no owner configured, because a door with no owner check should not open at all. [web]is global-file-only. Like[slack], it is stripped out of project-levelmecha.tomlfiles, so a cloned repository cannot describe the door to your own machine.- Writes must come through the app's request wrapper. The browser sends
X-Mecha-Request: 1on mutations, and requests identified as coming from another site are refused. This stops a foreign page from submitting an action using your ambient tailnet identity. Reload the app after upgrading; scripted API clients must include that header on writes too. - Opening a chat is explicit. The app opens it before loading the transcript
and subscribing to events, and repeats this when reconnecting. Scripted
clients should first send
POST /api/chat/{key}withX-Mecha-Request: 1; repeated opens are safe. Transcript and event-stream reads return 404 for a chat that has not been opened and never create one themselves.
[web]
owner_login = "you@example.com"
port = 63242
assets = "~/.mecha/web/dist"
Then front it:
tailscale serve --bg 63242
There is no password, no session cookie and no login page, and that is
deliberate: the tailnet already proved who you are, and a second secret
would be one more thing to leak. If you can reach the port, Tailscale says
you are the owner; if the header disagrees, you get a 403 before the
router even looks at the path — including for paths that do not exist, so
an unauthenticated probe learns nothing about what the app contains.
What the pages do
The pages share the CLI's stores and core operations. Review and maintenance actions use the same command paths; chat and workflow lifecycle handling also run inside the server so active conversations stay coordinated.
| Page | What it is |
|---|---|
| Home / Today | Urgent work, decisions, verified results, and waiting items, followed by store counts and doctor findings. Workflows connect tasks to conversations, drafts, questions, and completion evidence. Finished workflows can be reopened. Unreadable sources are shown as unavailable. |
| Chat | A streaming conversation with the agent — steering, cancel, a context gauge, approval cards, and a session drawer holding both live and recorded conversations. The waveform button opens a voice call into this conversation |
| Two tabs: the triage queue (what needs you, classified) and a plain inbox (what just arrived). Reading, triaging, and drafting — see mail | |
| Graph | One surface over the knowledge graph: capture a note (typed or dictated) with the graph's own confirmation of what it linked, a recent-notes list with in-place edit, one find field over everything (hybrid keyword + semantic search, ⌘K focuses it), and entity pages — facts with Confirm/Refute on anything unreviewed, aliases, per-source coverage, the connected neighborhood, history with superseded facts, and recent evidence. Opening an entity is itself a review trigger; see the graph. The old #notes address still works |
| Review | Outbox, the graph's merge queue, and the front door — every approval surface in one place |
| Tasks | The GTD board, with the views in a drawer and one tap per status change |
| Settings | Behind the gear — upper-right on every view, since settings is chrome rather than a place to be. An index of three features, each opening its own pane: the charter (edited as a list — tap a line, drag its grip to re-rank — behind a validated two-tap save), the learning store — reflections and rules, read, edited and refused — and the voice stack's health and preferences. Each row says what is in there — a count, or a dash where the store could not be read |
Open the same chat on multiple devices to follow one conversation. Typed messages, spoken turns, and steering appear on every connected page; sending from one device does not duplicate its message there. Steering first shows as queued, then as steered when the model receives it. If the run ends before receiving it, the message says not delivered — send again.
Stopping mecha serve with Ctrl-C or SIGTERM stops new turns, closes pending
questions, and saves active chat and voice turns before exiting. Tool calls
finish at their normal safe stopping points, so shutdown can wait for a tool
already in progress. A second Ctrl-C or SIGTERM forces termination and may
lose unfinished turns. The checked-in service units allow up to 180 seconds
before systemd forcibly stops the service; install the updated unit alongside
the binary when upgrading. Reload after restarting to read the saved partial
turn.
Settings, and what a browser may write
Four things on this page write, and each is fenced for what it is putting on disk. Everything else is a read.
The charter is what every run's prompt carries, so its fences are about what a valid charter is. The body is refused above 64 KB before the TOML parser sees it — a document orders of magnitude past a 2,000-character rendered budget is not an edit of one, whatever it parses as. It is then validated by the same reader every run loads through, so a document the server would not load never reaches disk. The write is a temp-sibling-and-rename in the same directory, so a crash between the two leaves the old charter whole. And the save is two taps, because one stray tap must not rewrite what rides in every prompt. The page edits the lines as a list and re-ranks them by dragging a grip — position in the file is the ranking, and there is no rank field to type — but the request is still the whole document through that one validated route. The owner authors every line either way: no model composes, suggests or edits one.
The learning store is the same argument one stage on: a reflection is a
lesson mined from the owner's own corrections, and a learned rule rides in
every future prompt's cached prefix, so disagreeing with one is the whole
reason the store has a reader. The pane carries the two the TUI's /learning
has, with the same verbs — a reflection can be edited, dropped or restored, a
learned rule retired or restored. Editing is offered at the reflection
because that is where disagreeing is cheap: a rule is a consolidation of
several lessons, so objecting once one exists costs the good ones too. It is
also a provenance promotion rather than a text
change — a lesson you write is yours, so the gate stops excluding it, and what
was happening is withheld because that is the field any third-party text was
in. Which is why an unchanged save is refused rather than accepted: the
promotion's whole justification is that the owner typed the words, and
re-submitting the model's own sentence would launder them into the rules.
Neither refusal deletes anything — a drop and a retirement are both flags, both
stay on the page as evidence, and both undo.
A cloned voice is a WAV in the voices directory, and the file is the
voice — the TTS resolves a name to that exact recording as its cloning
reference — so its fences are about what lands. audio/wav is required, which
is deliberately not one of CORS's "simple" content types: requiring it
forces any cross-origin caller through a preflight this server never answers,
which a raw-bytes route would otherwise not get. A closed name alphabet
(lowercase, digits, - and _, at most 40 characters, never default) is
what stops a name being a path. 32 MB is refused before any parsing; then
integer-PCM only, a header the payload has to back up, and 5–120 seconds,
because a reference the model cannot condition on is a voice that speaks
garbage. An existing name is refused rather than overwritten, and the write
is the same temp-and-rename, so a dropped connection cannot leave half a
reference the TTS would happily read.
Deleting a cloned voice is offered where recording is, on purpose: a botched take removable only at a terminal turns the store into a pile, and a recording of somebody's voice should be as easy to remove as it was to add. The same closed alphabet is the containment — the path is built from a validated stem, never from anything resolvable.
Two things this page deliberately does not do. Accepting a rule proposal
stays in the queue and the TUI, because a proposal is a
whole rewritten rule set and reject-all is not an objection worth offering on a
phone. And every learning verb runs as a mecha reflections … / mecha rules …
child process, so there is nothing a browser can do to that store which the
command line cannot — the promotion, the withholding and the git commit behind
each write all live in one implementation.
The voice panel's worker line answers whether the stack is reachable and where this process would send an offer; the worker is configured where it runs.
Chat, and what a session is
The agent lives in the serve process, so the phone is a view onto it rather
than a second copy. One agent, one provider connection, one cached prefix —
and many conversations, each with its own RunContext: its own
workspace jail under ~/.mecha/work/web/<key>/, its own permission mode,
its own cancel token and steering queue.
The drawer lists the conversations this process is holding and the ones recorded earlier, including voice calls. Opening a recorded one resumes it: the messages come back and so does the taint — a conversation that read a hostile page last Tuesday still remembers on Thursday, because resuming must not launder what a session touched.
Permission modes, and answering from the phone
A web session starts read-only: reads run, and anything that would send
is staged in the outbox instead. Switching a
session to ask turns every other tool call into an approval card on the
page — with a real reason field, because a denial with a reason is a
correction the learner can use and a bare "no" is
not. allow runs them without asking.
The chip in the header is the control and the display, and it cycles in
ascending order of what runs unasked. Entering allow asks first; leaving
it does not — every other change only ever adds a gate, and a
confirmation on a harmless change is what teaches people to tap through the
ones that matter.
Four properties worth knowing:
- The chip tracks the session, not your tap. The mode travels as its own event, so changing it on the phone moves the chip on the laptop watching the same session, and a request whose response was lost leaves the chip where the server actually is. What the chip is for is telling you whether the next write stops to ask, so a stale one is a security cost rather than a cosmetic one.
- A card shows the call the way a person reads one. A calendar call leads
with its title and when it is — in reading order, not alphabetical, where
an event reads end before start — and a letter leads with its addressing
and its prose. The whole call is one tap away and nothing is hidden: an
argument with no header or body shape, which is where
shellkeeps its entire contents, is shown outright. - A card is claimed atomically. Answer on the laptop and the phone's copy goes stale rather than double-answering.
- A pending card survives a locked phone. The card rides the transcript read, so reloading the page lands you back on the question instead of on a run that silently parked.
Read-only tools never generate a card in any mode — web_search, fs_read
and recall declare themselves read-only and are allowed without asking,
which is why turning on ask does not make a research run unusable.
An unanswered card times out as blocked by policy, not as a user denial — machine refusals and human corrections are different facts and only one of them should teach the learner anything.
Files
Attach a file in Chat and it lands in that session's inbox/ inside the
workspace jail; the path is what goes into the message, so the model
reaches it with fs_read and the taint arms through the ordinary file tool
rather than a parallel route. Downloads prove containment the same way every
model-supplied path does — canonicalize, then require the result to sit
inside the jail — and anything outside it and anything missing are the same
404.
Only images are served with a renderable content type. Everything else downloads as inert bytes, because a file in the jail may be model-written and HTML served from your own origin would run script against this very API.
Dictation
The mic button on the graph page (capture and find) and the task capture boxes records, encodes the clip in the page, and posts it to Parakeet running on your own machine. The audio does not leave the box — which is the entire reason not to use the browser's built-in speech APIs, which ship your voice to a third party.
Dictation needs the speech server from the voice stack; without it, the button reports that the transcriber is unreachable and typing still works.
What it does not do
- It is not a second agent. The TUI and the Slack connector still build their own; three agent-owning processes against one llama-server is the live shape, and whether serve should become the shared backend is an open question rather than a plan.
- It does not put
allowone tap from the default. The mode exists on the page now, and the argument against it was never wrong — a surface that can grant blanket permission from a phone is a surface that will, one distracted tap at a time. What answers it is a confirmation on the way in and nowhere else, plus whatallowstill cannot waive: the interlock refuses a send once the conversation holds both private and outside content, whoever approved what, and outbox-routed calls stage rather than send.allowremoves the tap, not the boundary. - There is no push yet. A page that is open streams; a page that is closed catches up on reload. Slack remains the channel that can reach you when nothing is open.
- It does not compose a charter line. The settings editor hands you the file's own bytes — a comments-only template when none exists — and refuses a save that would not load. There is no generate button, and no model path that writes one, for the reason the charter gives.
Installing the app itself
The web assets are a build artifact, not part of the crate — so
cargo install updates the binary and not the pages:
cd <checkout>/web && npm ci && npm run build
rsync -a --delete dist/ ~/.mecha/web/dist/
systemctl --user restart mecha-serve.service
Verify the served page rather than the directory: load the door and check
the bundle changed. A stale dist next to a fresh binary is the failure
that looks exactly like nothing happening.
The commands
mecha serve # the door, on [web] port
mecha serve --port 8080 # override for one run
mecha serve --owner-login you@ex.com # override the owner for one run
mecha serve --voice-port 8990 # mount the voice facade in-process
mecha serve --assets ./web/dist # serve a build from somewhere else
scripts/mecha-serve.service is the systemd unit that keeps it up.