MultiZone:基于 Tauri + React + Rust 的桌面 LLM 聊天应用
JammingDino/MultiZone2
MultiZone 是一款基于 Tauri + React + Rust 构建的桌面 LLM 聊天应用,支持多命名 zone(LLM 配置)、透视模式(同一消息并行发送至多个 zone)、项目、标签、文件附件及含网页搜索的可扩展工具系统,全部本地运行并直连用户配置的服务商,无账号、无中间服务器。
MultiZone
A desktop LLM chat application built with Tauri + React + Rust. Supports multiple named "zones" (LLM configurations), perspective mode (send the same message to several zones simultaneously), projects, tags, file attachments, and an extensible tool system including web search.
Everything runs on your machine and talks directly to the providers you configure — there is no MultiZone account, and no server in the middle.
Install
Download the latest MSI or NSIS installer from the Releases page and run it. Windows 10/11 already ships the WebView2 runtime MultiZone needs.
Prefer to build it yourself? See CONTRIBUTING.md.
First run
On first launch, go to Settings to add a provider (any OpenAI-compatible API endpoint + key), then create a Zone pointing at that provider and choosing a model. The zone library also ships curated zones you can install in one action.
Perspective mode
Each zone is its own provider, model, system prompt and toolset — a frontier API, a self-hosted endpoint, or a model on your own GPU. Add a zone as a perspective and every message you send fans out to all of them in parallel: one shared thread, independent replies you can read side by side. There is no fixed number — one zone or twenty, whatever you configure.
Multizone mode — the Response Leader
Where perspective mode shows you every model's answer, Multizone mode resolves them into one. A zone flagged as a Response Leader doesn't answer from its own knowledge: it spawns a panel of sub-agent zones via spawn_subagent, briefs each one on a deliberately opposing angle rather than forwarding your message, cross-examines them against each other with send_subchat_message, and only then writes a single synthesized answer.
While a leader is driving them, sub-agents can't reach you — their ask_user calls are suppressed, since a question raised in a chat nobody is watching would wait forever. The whole leader → sub-agent call tree is inspectable inline in the stack tracer, with every subchat transcript openable, and you can open a subchat and talk to the sub-agent yourself: it takes messages like any other chat, exports like any other chat, and can ask you a question when you are the one who sent. Exporting the chat keeps them: sub-agent conversations are nested under the turns that spawned them in both the Markdown and PDF exports, with each turn attributed to the zone that wrote it, so a prompt from the leader is never presented as something you asked for. Turn it off under Settings → Appearance → Chat export when you only want the conversation you were part of.
Sub-agents don't have to run one at a time. spawn_subagent/send_subchat_message take background: true, which returns the subchat id immediately and runs that sub-agent on its own task — so a leader can put five specialists to work in a single message, keep reading code while they think, and pick every reply up with collect_subagents. list_subchats shows the sub-agents a conversation already has, and the leader is told to continue one rather than re-brief a fresh copy. Stopping the chat stops its background sub-agents with it.
Turn any zone into one with the Response Leader toggle in the zone editor (it auto-enables the subchat tools), or start from the curated "Response Leader" zone in the library.
Working one codebase together
Sub-agents have always had the real tools — read, write, search, shell — and they inherit the parent chat's project directory. What they lacked was any way to know the others existed, which left only two safe patterns: run them one at a time, or have them hand patches back for someone else to apply. Agents working against each other, carefully sequenced.
The teamwork tool is the coordination layer that makes them work alongside each other instead:
claim_filestakes an advisory lock on the files an agent is about to edit, with a stated intent. A write to a file another agent holds is refused by the tools, so a claim isn't a convention someone can forget. An unclaimed write auto-claims, so the protection holds even for a zone that never calls the tool; claims expire, and release when the agent's turn ends, so a crashed agent can't own a file forever.post_note/team_statusare the session's shared board. Parallel edits only compose if the decisions travel with them — "parse()returns None now instead of raising" has to reach whoever is editing the caller, and one sub-agent can't read another's transcript.
Reads are never blocked, and an ordinary single-zone chat never touches any of it.
Zone teams
Some presets only work as a set. The library's Teams section installs one in a single action — a leader plus the specialists it delegates to, all bound to your default model.
The shipped Code Team is a general codebase collaboration: talk to Code Team Lead like a colleague about a symptom, a feature or a piece of code that annoys you, and it runs the team over your project. It clarifies the ask, has Code Scout map the ground, writes the contract the others must honour, then splits the work into file-disjoint slices that Code Implementer (Careful) (0.15) and Code Implementer (Inventive) (0.85) edit at the same time while Code Test Author writes the tests against the same contract — with Code Reviewer attacking the result and Code Verifier running the builds and suites on the combined tree. For a hard bug it switches to compete mode instead: both implementers attack the same problem independently, hand back diffs, and the leader picks on test evidence.
Seven zones on one model at seven temperatures: the value comes from independent attempts and adversarial review, not from a bigger model.
Every member is equipped like an agent you'd actually want on the job — web search and full page reads (an unfamiliar library's real API beats a half-remembered one), skills, the code runner, and shared memory at project scope, which is the durable counterpart to the board: how this repo's tests are run, or a trap someone hit, is injected into every agent on the project, including the ones spawned next week. Before a long run, raise Task length to 60 or more and deal with approvals — a sub-agent cannot show you a prompt, so anything it must ask about will simply stall. Since 0.14.0 that no longer means turning the safety off wholesale: under Settings → Chat → By kind of work, set read, web and sub-agents to Auto and leave shell on Ask, and add the commands you are happy to see run (npm run test, git status) to the allow list beside it. The zone editor has the same controls per zone, which is how a scout that only reads and an implementer that may edit stop being the same policy. Where edits may land is the same idea over paths (0.14.5): put {project} in the allow list and edits inside whichever project the chat is in stop prompting, while anything outside it still asks — a list here is a boundary rather than a shortcut, because being asked about exactly those is the reason to draw one.
Web tools
Web research works out of the box with no API key and no third-party search service. Three tools (0.9.7+), based on Hound:
| Tool | What it does |
|---|---|
smart_search |
Searches seven keyless engines in parallel — DuckDuckGo, Bing, Brave, Yandex, Ecosia, Yahoo, Wikipedia — and merges them with Reciprocal Rank Fusion, so a page several engines agree on ranks highest. If one engine is blocked or rate-limited the others still answer, and the result reports which engines contributed and which failed. |
smart_fetch |
Reads one or more pages or PDFs in full as clean markdown, boilerplate stripped, with an optional relevance query to trim a long page to what matters. |
smart_crawl |
Follows links within one site and reads several pages in a single call, visiting the most relevant first. |
All three run entirely from your machine, through a browser-emulating HTTP client that carries a real Chrome TLS/HTTP-2 fingerprint. There is no headless browser, so pages that render entirely via JavaScript — or that sit behind an interactive bot challenge — are reported as such rather than returned blank.
Enable them per-zone in the zone editor; they are on by default in the curated research zones.
Legacy web_search / extract_url
The original single-engine tools are still available, mainly for the key-based providers. Configure under Settings → Web search provider (applies to every zone with the tool enabled):
| Provider | Requires | Notes |
|---|---|---|
duckduckgo |
nothing | Default. Single-engine HTML scraping — prone to anti-bot challenges; prefer smart_search. |
searxng |
endpoint |
Self-hosted SearXNG instance URL. |
brave |
api_key |
Brave Search API. |
tavily |
api_key |
Tavily. |
serper |
api_key |
Serper (Google via API). |
Terminals that stay open
bash runs a command and waits for it to exit, which cannot express starting something — a dev server, a REPL, a log to follow, a program that asks a question part-way through. The terminal tool group (0.9.11) keeps a process alive between calls instead:
| Tool | What it does |
|---|---|
terminal_start |
Start a process (or a bare shell) and get back a short id (t1). Returns immediately; the process keeps running. |
terminal_write |
Type into it — a command, an answer to a prompt, a password. Returns only the output that followed. |
terminal_read |
Read what it has printed. Pass back the cursor from a previous call to get only what is new. |
terminal_list |
The conversation's terminals: label, whether each is alive, how much output is waiting. |
terminal_stop |
Stop one (or all) and return the final output. |
Driving an interactive program is a timing problem, so every call that can wait takes delay_ms (wait before typing — start a server, send the sudo password five seconds later), wait_for (a regex to wait for in the new output, with timeout_ms, reporting whether it matched), or wait_ms (collect for a fixed span).
Waiting on work is a different question, and until answers it in one call: until: "exit" returns at the moment the process finishes, until: "idle" when its output has been quiet for idle_ms (a server that has finished booting), until: "output" at the next thing printed. waited_until says which condition ended the call. Without it, a model watching a slow build has to read the buffer over and over — paying for the transcript every turn and still finding out late.
Reader tasks drain stdout and stderr continuously, so output printed between tool calls is still there when you look; ANSI escapes are stripped and a \r-redrawn progress line collapses to where it landed. Terminals are visible to the whole session — a leader's dev server is one its own sub-agents can query — and are never reaped on idle: they end when stopped, when the process exits, or when the app closes.
stdin/stdout are pipes, not a PTY. Servers, build tools and REPLs work; programs that insist on a real terminal do not — pass sudo -S, expect ssh password prompts and full-screen TUIs to fail, and stop a process with terminal_stop rather than trying to send Ctrl-C. Children block-buffer on a pipe, so unbuffer where it matters (python -u, stdbuf -oL); PYTHONUNBUFFERED is set for you.
Replaying a session
The clock icon in the chat header opens Replay — the run as it actually happened, in order, at whatever pace you read at (0.12.2+). It is not the transcript scrolled back: it carries the parts the transcript never had, like an approval you declined, a tool that failed, a zone that switched mid-turn, or a plan you rewrote before approving.
From 0.12.3 it carries the conversation too, woven into the same timeline: what you asked, what each zone answered, and every tool call with its arguments, its output and how long it took. That is the point — "where did that number come from?" is answerable after the fact and not only while it scrolls past. Each row shows elapsed time from the first event alongside the wall clock; Play steps at a fixed cadence for skimming, and the chips filter by kind.
Running in the background
0.18. Closing the window hides it to the notification-area tray instead of quitting. Nothing that runs a turn lives in the window, so agents keep streaming, running tools and spawning sub-agents while it is hidden, and scheduled runs keep firing. Click the tray icon to bring the window back; quit from the icon's menu. Launching MultiZone again while it is in the tray shows the running copy rather than starting a second one.
Prefer the close button to quit? Settings → Chat → Closing the window.
Stop means stop, right away. The stop button releases the composer the moment you press it — including while a local server is still prefilling a long prompt, or a provider has not answered at all. The request is dropped on the app's side (which also tells llama.cpp-style servers to abandon the prefill), and anything you send before the old turn has finished unwinding is queued behind it and delivered, not lost.
Scheduled runs
0.18. A prompt on a clock: once at a given time, every N minutes, every day, or on chosen weekdays. Each firing either starts a new chat (an inbox sweep every day at 12:00) or adds to an existing chat (a reminder an agent leaves itself). A new-chat run can also report on another chat — each firing is handed the latest of that chat's conversation, which is what a half-hourly progress report on a long-running agent needs.
Set them up under Settings → Schedules: create, edit, switch off, Run now, and see when each last ran and whether it failed. A run fires through the same path as a message you typed, so its zone's tools, approvals and the stop button all behave as usual; the model is told the message came from a schedule and that nobody may be watching.
Agents can schedule runs too, with the Schedule tool group (schedule_run, list_scheduled_runs, cancel_scheduled_run). It is classed dangerous — a scheduled run is a turn nobody typed — so it is never on by default and every schedule_run asks for your approval. From a tool, a run defaults to once, in this chat.
Runs fire only while MultiZone is running (in the tray counts). A run that came due while the app was closed fires once at the next start, not once per missed time.
Keeping long chats small
Three ways to shrink what the model re-reads, none of which change what you see — the whole conversation stays on screen and in the database:
| Costs | What it does | |
|---|---|---|
| Smart compaction (0.18) | Nothing — no model call | Older tool results are trimmed to their head and tail; a call repeated later with the same arguments keeps only its newest result (the same file read twice); long tool inputs — the whole file a write carried — are replaced by their length; inline thinking is stripped from old answers. |
| Summary (0.9.3) | One request the size of the chat | The model writes a summary that stands in for the earlier turns. |
| Rolling context (0.18) | Nothing | Past a token limit, the oldest messages are forgotten — except what the model marked important. |
The compact button under the composer (the folding-arrows icon) runs either compaction on demand and holds the chat's rolling-context setting. Automatic compaction, which kicks in as a chat nears its model's window, now trims first and only pays for a summary when trimming was not enough. The model can do either itself with smart_compact and compact_context (the Condense a long chat tool group). Tune the trimming under Settings → Chat → Smart compaction.
Rolling context suits long unattended runs. Set a default under Settings → Chat → Rolling context (0 is off) or per chat from the compact button. While a chat is rolling, the model gets mark_important and forget_important, and its notes are shown at the top of what it can still see, whatever has been forgotten — a note can carry the exact text of a tool result too. Its system prompt is mostly about using that well: record what is decided, found and verified, check the notes before exploring or testing, and do not re-verify what a note says is done. That is the loop a forgetting agent otherwise falls into — explore, test, forget, explore again. The latest message you sent is never forgotten; in a long agentic turn, it is the task.
Editing a message
Hover any message and hit Edit. The editor is the composer you already use: the same box, the same paperclip, the same microphone, with the message's attachments shown as chips above it. Remove one and it is gone from the resent turn; drop another in — or paste a screenshot — and it goes with it. Editing one of your own turns re-runs the conversation from that point; editing an answer just corrects the text in place.
Voice dictation
Dictate instead of typing (0.8.0+). Transcription goes to a provider you configure under Settings → Dictation, using the OpenAI-compatible /audio/transcriptions endpoint — so pointing it at a local server (e.g. LM Studio serving a whisper model) keeps your audio on your machine.
Or run it on this computer (0.18). Under Settings → Dictation → Dictation on this computer, pick a model and press Download and use: the app downloads whisper.cpp's server and the model, checks both against their published checksums, and dictation uses them from then on — no account, no server to set up, and recordings never leave the machine. The server starts when you dictate and stops after ten idle minutes. Models run from Tiny (75 MB, fastest) to Large v3 Turbo (550 MB, best, every language); Base (English) is a good start on any machine. Ready-made for Windows and Linux; on macOS, brew install whisper-cpp first and the app uses that (Homebrew's build uses the Mac's GPU by itself).
On the GPU. With an NVIDIA graphics card on Windows, the section offers Use the GPU: a one-time 670 MB download of whisper.cpp's CUDA build, which brings its own CUDA libraries, so only the ordinary NVIDIA driver is needed. On an RTX 4070 Ti SUPER, Large v3 Turbo transcribed an 11-second clip in 0.13 s against about 16 s on the CPU. The same download runs on the CPU when the toggle is off, or by itself if the GPU cannot be used (an old driver, say), so it never leaves dictation broken. AMD and Intel GPUs, and Linux GPUs, are not offered: whisper.cpp publishes no ready-made build for them.
Dictation behaves like typing: start a recording with text selected and what you say replaces the selection, leaving the caret after it. With nothing selected, the transcript is inserted at the cursor — unless Settings → Dictation → Insertion mode is set to Replace field, which always overwrites the whole composer.
From a phone, too (0.17.4+). This is the one input where being a remote is an advantage — a good microphone in your hand, a transcription provider on the machine at home — so the phone records and your computer transcribes. It is not a relay: your computer converts the recording to 16 kHz mono WAV with ffmpeg before handing it on, which is the same format its own microphone produces, so a local whisper or MLX server accepts audio from either device. Without ffmpeg installed the recording is passed through as recorded, and if the provider refuses that format the error says which piece is missing. Set MULTIZONE_FFMPEG if yours is not on PATH.
The one honest difference from dictating at the computer: no live preview. The desktop re-transcribes the utterance every couple of seconds, which over a LAN would mean re-uploading the whole recording on every pass — so the phone shows no words until you stop, rather than showing stale ones.
Audio uploads
Dictation needs you at the microphone, and it only helps with what you are about to say. Drop an audio file into any composer instead — MP3, WAV, M4A, MP4, FLAC, OGG, WebM — and it is transcribed through the same provider and sent as text (0.12.0+). Anything that is not already WAV goes through the same ffmpeg conversion described above, so the list of formats that work is the list ffmpeg reads rather than the list your transcription server happens to.
The point is that the model never sees audio. Any zone works, including text-only models, because what arrives is a transcript — so a voice memo, a meeting recording or an interview reaches a model that has no audio input at all. It also means a recording you would never sit through dictating in real time is one drag away from being something you can ask questions about.
Two choices under Settings → Dictation → Audio uploads, both overridable per recording on the composer itself:
| Setting | Options |
|---|---|
| When a transcript is ready | Use it — the words go straight into the message box. Review first — the chip holds the transcript for you to read and correct; click it to open an editable transcript, then Use as message. |
| How it reaches the model | As my message — the transcript is the text you send. As an attachment — the model gets the full transcript as context while the message box stays yours, which is the mode that works for anything long: attach the meeting, then type "what did I agree to?". |
Turning on Include timestamps and language asks the endpoint for verbose_json, which prefixes the transcript with its duration and detected language and breaks it into [0:00]-stamped lines, marking any passage the model itself flagged as unclear. Not every OpenAI-compatible server implements it; one that doesn't still returns a plain transcript.
Language is auto-detected by default (the Language setting above forces one instead). Size and duration ceilings are yours to set — 25 MB and two hours out of the box, the first because that is OpenAI's own hard limit for the endpoint. Raise them for a local server with a different one.
There are no speaker labels. OpenAI's transcription endpoint does not diarize, and a guess at who was talking, presented as data, is worse than not offering it.
Appearance
Settings → Appearance covers the palette, fonts, bloom and shadows, plus an animated background effect — every one of which reacts to your cursor, and none of which need it to keep moving:
| Effect | What it does |
|---|---|
| Particles | A drifting field wired together by proximity lines; the cursor pushes them aside. |
| Orbs | Slow layered colour clouds that swirl, bloom and drift apart under the cursor. |
| Aurora | Curtains of light rippling across the top of the screen, bending toward the cursor. |
| Grid | A pulsing grid with a highlight band sweeping across it. |
| Stars / Shooting stars | A parallax starfield, optionally streaked by meteors. |
| Waves | Layered sine curves that rise and fall with the pointer. |
| Fireflies | Wandering lights that pulse and drift gently toward the cursor. |
| Boids | A flock that aligns, crowds and scatters from the cursor like a predator. |
| Matrix | Falling glyph rain that fades behind itself; the cursor burns the columns it passes brighter. |
| Topography | Breathing contour lines of a slowly morphing landscape, with the cursor pushing a hill into it. |
| Puzzle | An interlocking jigsaw where pieces near the cursor lift, tilt and light up. |
| Mountains | Parallax ridgelines under a drifting sun or moon, with a starfield above. |
| Fish | Procedurally animated fish — jointed spines that swim with fins and a trailing tail — schooling, and darting away when you get close. |
Four sliders shape whichever you pick: Speed, Density, Opacity, and Hue variation, which spreads each element's colour around your accent instead of painting everything one flat tone. Leave it at 0 for a single-colour look.
Every colour control takes a hex value as well as the swatch — type or paste #4f9cf9 (the # is optional, and #abc shorthand works) instead of converting it to three numbers in the OS picker.
Whatever you choose is on screen from the first frame of the next launch (0.12.3). The preferences live in the database, so the window used to paint before it knew what it should look like — a light-mode install flashed a dark panel, and a changed interface size resized the whole UI a beat after it appeared. The last appearance is now restored before the first paint, and the app's mark is drawn over the remaining gap while the chat list and background arrive; click, tap or press any key to skip it.
Custom CSS
Settings → Appearance → Custom CSS (0.11.3) takes a stylesheet of your own, loaded after the app's — so an equally specific rule wins and you don't have to reach for !important. It has its own on/off switch, which matters more than it sounds: a rule that hides the wrong thing can be switched off without first finding it in the text.
Write colours as the theme's variables (--color-bg, --color-panel, --color-panel-hover, --color-border, --color-text, --color-text-muted, --color-accent) rather than as literals, or the sheet breaks the moment you switch between light and dark.
.sidebar { width: 220px; } button { border-radius: 2px; }
Everything in this section — the palette, the effects and the custom sheet — is also reachable over the API and by the assistant itself; see /api/theme below.
Connectors (MCP)
Settings → MCP → Browse catalog. A catalog entry carries everything about a Model Context Protocol server except the part only you have: the command or URL, the environment variables or headers it needs, what has to be installed first, and a link to the page each credential comes from. Installing one writes the server and stops — nothing is launched or contacted until you press Connect, and then you set a danger level per tool and enable the ones you want per zone.
From the next launch on, enabled servers start themselves (0.14.0). Every server whose own switch is on connects in the background as the app opens, so its tool list is current before the first message rather than after the first tool call, and a server that has broken since you last used it shows as a red row in Settings instead of surfacing as a failed step mid-turn. Turning a server off is how you stop it starting; Connect is now for reconnecting after a change or a failure.
The shipped set covers Gmail, GitHub, Context7, Tavily search, the reference filesystem server and
Playwright. It is a JSON file, not code: Import entries from a URL adds more (one entry, a list,
or { "entries": [...] }), they land as files in the app's connectors/ folder, and an imported
entry sharing an id with a shipped one replaces it.
Hosted servers authenticate. MultiZone sends the Headers you configure with every request on the
sse/http transport, which is where an Authorization: Bearer … goes — catalog entries fill it in
from a pasted token, and the manual editor has the field. Google's
Gmail MCP server is
the worked example: enable the two APIs on a Cloud project, set up the consent screen with the
gmail.readonly and gmail.compose scopes, then paste an access token from
gcloud auth print-access-token into the catalog entry. Google's tokens last about an hour, so this
is paste-again-when-it-expires until signed-in connectors (OAuth with refresh, per-scope consent,
tokens in the OS keychain) land after 1.0.
Diagnose answers the question "why isn't this working" properly. It runs the checks in the order
they can fail — is the server enabled, is there a command, is that program on the PATH the app
inherited, are the values the catalog marks required actually filled in, and only then the handshake
— and reports the first false one with what to do about it. A stdio server's own stderr is captured
and quoted, so "the command ran, the server exited, GOOGLE_CREDENTIALS_PATH is not set" replaces
"failed to connect".
The assistant can do all of this except the writes: app_read reaches the catalog and the
diagnosis, so "why can't you see my email?" is answerable, while installing a connector goes through
app_control and therefore through an approval prompt. Silently adding a server that launches a
process is exactly what an injected instruction would ask for.
HTTP API
MultiZone can expose a local HTTP API so external tools or scripts can drive it like a CLI — listing and creating chats, picking zones/projects, and sending messages with the same agentic loop the GUI uses.
Enable it under Settings → API: toggle it on, optionally change the port, and copy the auto-generated bearer token. The server binds to 127.0.0.1 and every request must include Authorization: Bearer <token>. Reaching it from another device on your network is a separate switch, under Settings → Phone & remote — see Your phone as a second window.
A handful of routes wrap the value in a one-key object for readability — GET /api/settings/:key answers {"key":…,"value":…} — and each one now declares that in the coverage table the route map is generated from, so a client cannot be handed an envelope it does not know about. A test reads the handlers and fails the build if a route wraps something without saying so.
Base URL: http://127.0.0.1:8765 (default port).
The API describes itself, so this table cannot be the only record of it. Two routes answer without a token, because a caller working out why nothing else responds should not have to authenticate to find out that its token is the problem:
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/routes |
Every route this build serves, with a one-line description each. |
GET |
/api/health |
enabled · bound · answering · tokenPresent · tokenAccepted, plus the app version, route-set version, and the address it bound. Six separate questions with six separate answers — if you cannot reach the app, this tells you which one is false. |
POST |
/api/pair |
Redeem a pairing code for a per-device token (0.17.0). The only unauthenticated write, and it can only succeed while a code is on screen on the desktop. Body: { "code", "name"?, "platform"? }. |
Everything else needs the bearer token. Rather than reproduce the whole surface here — where it would go stale, as it did — ask the app:
curl http://127.0.0.1:8765/api/routes | jq -r '.routes[] | "\(.method)\t\(.path)\t\(.description)"'
The surface covers chats and messages, zones, providers, projects, tags, perspectives and sub-agents, skills, memory, MCP servers, knowledge indexes, checkpoints and the review queue, tool and token usage, settings, and the markdown mirror — reads and writes both, so a script can provision the app rather than only drive it. A test pairs every Tauri command with either a route or an explicit "GUI-only" reason, so a new capability cannot ship API-invisible without failing the build.
The most-used handful:
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/chats |
List chats. |
GET |
/api/search |
Search every message of every chat. Query: ?q=. Returns the matching message, its chat, and a snippet with the hits wrapped in the STX/ETX control characters (U+0002 / U+0003). |
POST |
/api/chats |
Create a chat. Body: { "zoneId"?, "projectId"? }. |
GET |
/api/chats/:id/messages |
List a chat's messages. |
POST |
/api/chats/:id/messages |
Send a message (see below). |
POST |
/api/chats/:id/regenerate |
Re-run the last turn. |
POST |
/api/chats/:id/cancel |
Cancel the in-flight stream. |
POST |
/api/chats/:id/zone |
Set the primary zone. Body: { "zoneId" }. |
POST |
/api/chats/:id/approval |
Answer a pending tool approval. Body: { "approved", "zoneId"?, "hunks"? }. |
GET/POST/DELETE |
/api/chats/:id/perspectives |
List/add/remove perspective zones. Body for add/remove: { "zoneId" }. |
GET |
/api/zones · /api/projects · /api/tags |
List each. POST to create or update; DELETE /:id to remove. |
PATCH |
/api/settings/:key |
Merge fields into a JSON settings row — PATCH /api/settings/theme -d '{"mode":"dark"}' switches to dark mode without re-sending every other appearance setting. PUT still writes a row whole. |
GET/PATCH |
/api/theme |
The appearance settings in force, plus a schema for them: every field with its type, range, default and meaning, and the palette-key ↔ CSS-variable table. PATCH validates what you send and names what is wrong with a patch it rejects, rather than writing a misspelled field and reporting success. |
Sending a message accepts either { "text": "..." } or { "parts": [...] } (the same content parts the GUI uses). By default the response is a Server-Sent Events stream of the same events the GUI receives (tokens, tool calls, etc.). Append ?wait=true to instead block until the turn finishes and return the final messages as JSON.
# Create a chat with a specific zone curl -X POST http://127.0.0.1:8765/api/chats \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"zoneId":"<zone-id>"}' # Send a message and stream the response (SSE) curl -N -X POST http://127.0.0.1:8765/api/chats/<chat-id>/messages \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"text":"Hello"}' # Send a message and wait for the final result as JSON curl -X POST "http://127.0.0.1:8765/api/chats/<chat-id>/messages?wait=true" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"text":"Hello"}'
API-driven activity also updates any matching chat open in the app live — and so does everything else it writes. A zone created over the API appears in the sidebar, and a theme changed over it is the theme you are looking at, without a restart.
Your phone as a second window
0.17.x. MultiZone on a phone is a remote for your computer, not a second app. Same chats, same zones, same projects — you send from the phone and the machine at home runs the turn, with its providers, its models, its files, its MCP servers. Nothing infers on the phone and nothing is stored there.
That shape is deliberate, and the alternative is the tempting one. A phone cannot run a 30B local model, which is the premise of the product. Every tool that matters — read, the shell, the knowledge index, an npx MCP server — is meaningless without your computer's filesystem. And two independent stores would have to sync, which is the cloud-sync feature this app refuses; a remote client has no second copy, so the hard problem is deleted rather than solved.
Getting the app. Every release carries an .apk alongside the desktop installers, built and signed by CI from the same commit as the Windows installer. There is no store listing; sideload it, and the in-app updater does not apply to it — a new version means downloading the new APK.
Android identifies an app by its signing key, so an install signed with a different key (a local debug build, say) has to be uninstalled before a release APK will replace it. Uninstalling clears the phone's saved computers and its pairing token; the desktop still lists the old device, which you can revoke under Settings → Phone & remote.
Setting it up. Under Settings → Phone & remote on the computer:
- Turn on Reachable from this network. The app says plainly that it is now on your network, and shows the address it bound. It binds one interface you choose, never
0.0.0.0. - Tap Pair a device. Nothing is shown yet — the computer is now waiting.
- Open the app on the phone. It sweeps your network for computers running MultiZone, or you can type the address. Tap Next.
- The code appears on the computer with the phone's name on it — six digits and a QR. Enter it on the phone, then name the device.
The ceremony runs that way round on purpose (0.17.4). "The desktop shows a code and the phone enters it" walks you between two screens neither of which knows about the other; having the phone ask costs one route and turns anonymous digits into an identified request. The security property is the easier one to state: no code exists unless somebody armed the computer.
The phone gets its own token, not a copy of yours. Each paired device is listed with when it was last seen, and revoking one signs out that device and nothing else. The code works once, expires in three minutes, and burns after five wrong guesses; every attempt is logged where you will see it.
Saved computers. Every computer you pair with is kept, so switching between the laptop and the desktop is one tap and no new code. Settings → Phone & remote on the phone names the computer it is talking to and offers Switch computer, which keeps every token — it is not a sign-out. If the connection drops, the banner offers Try again without re-entering anything.
Android's own controls work. Back closes the panel you are in — a dialog, Settings, the sidebar drawer — and only leaves the app at the top level.
Approvals reach the phone. A tool call waiting on a human appears at the top of the screen with what it would do and how long is left before it denies itself. That, plus per-category auto-approval (0.14.2), is what makes leaving a long run unattended a decision rather than a gamble.
Deliberately not built: any access from outside your LAN — no relay, no tunnel, no account, because that is cloud sync wearing a different hat — and no offline mode. If your computer cannot be reached, the phone says so rather than showing you a stale copy. The honest failure mode is your computer being asleep, and the app names it instead of spinning.
The desktop half stands on its own: the same LAN bind, pairing and device registry are what a tablet, a second laptop, or a script on your network needs, and none of it requires a phone.
Letting the assistant drive the app
The Change MultiZone itself tool group (app_control, 0.11.0) gives the model the same surface:
| Tool | What it does |
|---|---|
app_read |
GET any API path. Start with /api/routes — that is the model's catalog of what it can do. |
app_control |
POST / PUT / PATCH / DELETE any API path. Every call goes through an approval prompt. |
Both serve the request through the same router the socket does, in-process — so the tool needs no port, works whether or not the HTTP API is switched on, and cannot reach anything the API cannot. "Switch to dark mode", "make me a zone for code review", "file this chat under the Rewrite project" are then things the assistant does rather than describes.
Two routes are refused from inside a turn: sending or regenerating a message (a turn calling itself — that is what the subchat tool is for, under your supervision), and answering a tool approval (the model granting itself permission). The group is classed dangerous, so it never lands in a default toolset.
Privacy
Nothing is collected. No account, no telemetry, no analytics, no crash reporting, no cloud sync — there is no server to send any of it to. Chats, zones, settings and indexes live in one local SQLite file whose location Settings → Data shows you.
What leaves the machine leaves because you sent it: a message to a cloud provider, a web search, a page fetch, a remote MCP server, or an API-based voice endpoint. Run a local model with no web tools and nothing leaves at all. Two consequences worth stating rather than burying — a cloud model sees your files when a tool reads them, and a search query is a disclosure to the engines it fans out to.
Two things a short version would gloss over, so they are here as well as in the statement:
- The app checks for updates ~2.5 seconds after launch. A plain GET for a static manifest, no identifier, silent on failure. Settings → Data turns it off; with it off, MultiZone makes no network request you did not start.
- Provider API keys are stored in plain text in that SQLite file — not encrypted, not in the OS keychain. Treat the database as being as sensitive as the keys.
The full statement is docs/PRIVACY.md, and is also readable inside the app (Settings → Data → Read the privacy statement) without a network connection.
Contributing
Build instructions, project layout and conventions are in CONTRIBUTING.md.
License
来源:GitHub · github.com


