English | 简体中文
Complete enumeration of every endpoint. REST is JSON unless noted; the two SSE endpoints are
/api/events(chat / state) and/api/alerts(anomaly / audit).
All non-API routes return static files (server.js → serveStatic /
serveIndex).
- Base URL:
http://127.0.0.1:18090(or LAN IP if enabled) - Path prefix:
/api/ - Content-Type:
application/json; charset=utf-8for both request and response - Auth header: if
TOKENenv is set, every request must include either- query:
?token=… - header:
Authorization: Bearer … - 401 if missing or wrong
- query:
- CID: each request should include
?cid=<uuid>to identify the webui tab. The webui injects this automatically; if missing, the server falls back to thedefaultCID. - Errors: every error response is
{ok: false, error: 'human-readable message'}with an appropriate 4xx/5xx status. Some legacy endpoints still return{ok: true, …}even on soft failures — those are called out below.
Returns server status. No auth required, no CID required.
Response 200
{
"ok": true,
"port": 18090,
"defaultModel": "minimax_api/MiniMax-M3",
"defaultWorkspace": "C:\\Users\\you\\.minimax-code\\webui",
"mcodeCmd": "C:\\Users\\you\\.minimax-code\\mcode.cmd",
"mcodeVersion": "0.5.2",
"maxConcurrent": 3
}mcodeVersion is the engine's own version (from the ACP initialize
reply). It is "unknown" before a client has attached — the endpoint
does not pin a constant.
Account card data: display name, plan tier, and quota. Fetched on
demand rather than pushed in the SSE state snapshot — the snapshot is
broadcast to every subscriber including LAN clients, and account data
should not be in that channel. The engine holds the credential; webui
only relays the projection (see server/lib/mcode-rpc.js#getAccountStatus).
Response 200 (engine answered)
{ "ok": true, "name": "weekbin", "planTier": "max", "remaining": 86, "weeklyRemaining": 92, "resetAt": 1790164800, "weeklyResetAt": 1790524800 }Response 200 (engine unavailable — soft failure)
{ "ok": false, "reason": "no_client" }reason is one of no_client / rpc_error / account_unavailable —
the card renders its empty state, the route never invents a name or a
plan.
Returns the current state object for this CID. See
ARCHITECTURE.md §4 for the full shape.
Response 200
{ "ok": true, "version": "0.5.2", "running": {"active": false}, … }Server-Sent Events stream for this CID. The connection stays open indefinitely. Events are listed in ARCHITECTURE.md §5.
Response 200 (Content-Type: text/event-stream)
event: state
data: {"version":"0.5.2","running":{"active":false},…}
event: delta
data: {"text":"hello","isPartial":true}
event: exec
data: {"status":"ok","durationMs":12345}
The connection is held open until the client closes it (EventSource.close())
or the server shuts down. No automatic reconnect from the server side;
the webui handles reconnection with exponential backoff.
Independent anomaly / system-signal SSE channel. The chat/state
stream is per-CID; /api/alerts is global. The bell icon and the
audit log subscribe here. See server/routes/alerts.js for the wire
format.
Response 200 (Content-Type: text/event-stream)
data: {"kind":"snapshot","alerts":[{…}, …]}
data: {"kind":"append","alert":{…}}
data: {"kind":"update","alert":{…}}
event: heartbeat
data: {"ts":1730000000000}
snapshotis sent once on connect, carrying the 100-entry ring buffer's current contents.append/updatecarry individual alerts (id, level, message, source, dedupKey, count, firstSeenAt, lastSeenAt).heartbeatevery 30 s — keeps proxies from idling the channel out.
Send a user message. Spawns (or reuses) the mcode subprocess for this CID and streams the result via SSE.
Request
{
"content": "refactor the workspace picker to use a tree",
"attachments": ["@/home/you/.mcode-webui/uploads/1790228071891-8d8ec6.txt"],
"isAskAnswer": false
}content(string) — the user message. Required unlessattachmentsis non-empty: the composer deliberately enables Send for an attachment with no text, so a bare file is a valid turn.attachments(string[], optional) — uploaded files, as returned byPOST /api/upload. A single leading@is accepted and stripped (the desktop's mention convention, and what the composer sends). Each path must resolve insideUPLOAD_DIRand exist as a file; anything else is rejected — the reference text is fed to the model as if the user had typed it, so an unchecked path would let a caller name any file on the host. Duplicates are collapsed and the list is capped at 16 per turn (MAX_ATTACHMENTS_PER_TURN). Rejections and drops are counted and pushed to the alert channel rather than silently ignored.isAskAnswer(bool, optional) — whentrue, the content is the answer to an activeask_userquestion, and the server does not add a›line to the transcript. Set by the ask modal automatically.
How attachments reach the engine. As ACP resource_link content blocks —
[{type:"text",…}, {type:"resource_link", name, uri}, …] — because the
engine's own promptToText (packages/tui/src/acp/agent.ts) accepts exactly
text and resource_link and rejects anything else with "Prompt content type
X is not supported in ACP P0". The desktop's own resource / image blocks
are not valid here. On the mcode exec transport, which has no block
channel, the same wording the engine uses is written to stdin instead.
Response 200 {ok: true} immediately. The actual response is streamed
via /api/events.
Run watchdog. A run that stays silent for MCODE_WEBUI_PROMPT_IDLE_TIMEOUT
seconds (default 120) is aborted — every acp/exec stream event resets the
timer, so a run that keeps emitting never times out. The value is in
seconds and is exported as PROMPT_IDLE_TIMEOUT_MS (server/lib/config.js);
non-positive or non-finite values fall back to 120 s. This is a silence budget,
not a total-turn ceiling.
Errors
- 400 when
contentis empty and no attachment survives validation - 409 when a turn is already in flight —
reason: "cid-busy"(this client is busy),"session-busy"(another client is running this conversation), or"at-capacity"(the server is atMAX_CONCURRENT, which/api/healthreports asmaxConcurrent)
A 409 is terminal for that message, and it is not a failure. The turn is
never handed to the engine, the › line is never written, and nothing reaches
the persisted record — a refused send cannot be half-applied, and cannot be
one the engine ran while the transcript lost. There is no send queue: "refused,
try again when the turn ends" is the whole contract.
error is the user-facing sentence (the composer renders it verbatim) and
reason is the stable machine-readable key; branch on reason. For
cid-busy and session-busy that sentence states the conversation is already
running a turn and the message was not delivered, rather than repeating the
internal detail — which reads "another window" and is wrong for the common
case of the same tab sending again a moment later.
Cancel the current run. Tries session/cancel via acp (the cancel
notification is delivered to the active child subprocess; the route
server falls back to SIGTERM on the subprocess if the notification could
not be delivered, then SIGKILL after 2s).
Request {}
Response 200
{ "ok": true, "wasRunning": true, "cancelled": true, "hardKilled": false, "note": "gentle cancel" }wasRunning— whether an active child backed this cid.cancelled— thesession/cancelnotification was delivered. Only meaningful whenwasRunningand the turn is on the ACP transport; the exec transport has no engine session to notify, so it answerscancelled:falsewith the hard-kill note.hardKilled— the SIGTERM/SIGKILL cascade fired.note—"gentle cancel"or"hard kill (session/cancel could not be delivered)". This is the field to read; there is nokillEndpoint,warningorcodein this response.
An earlier revision of this document described {warning, code, killEndpoint} here. Those fields are not in the response; the hard-kill
cascade is this same route, so a client that needs it re-issues POST /api/stop.
Run one of the webui button commands. The accepted set is declared in
server/lib/interaction/command-registry.js#CMD_BUTTON_COMMANDS:
new, clear, status, sessions, review, help, usage, stop.
The command must be the bare /name form — these handlers take no
argument, and the dispatcher matches the whole text after the slash.
This endpoint does not forward to mcode. Engine commands
(/compact and friends) and the typed webui commands (/goal <text>,
/goal-done, /goal-blocked) belong to POST /api/send, whose
handleLocalSlash consumes the webui ones and forwards everything else
to the engine unchanged.
The response is written after the dispatch, so it reports the command's outcome, not the receipt.
Request
{ "cmd": "/clear" }Response 200 — the dispatcher claimed the command and ran it:
{ "ok": true, "cmd": "/clear" }Response 400 — no command claimed it; nothing was mutated:
{
"ok": false,
"error": "/api/cmd 不处理该命令:/compact。未知命令。可用的命令:/new、/clear、/status、/sessions、/review、/help、/usage、/stop;引擎命令(如 /compact)请作为普通消息发送。",
"reason": "unknown_command",
"cmd": "/compact",
"knownCommands": ["new", "clear", "status", "sessions", "review", "help", "usage", "stop"],
"suggestion": "未知命令。可用的命令:/new、/clear、/status、/sessions、/review、/help、/usage、/stop;引擎命令(如 /compact)请作为普通消息发送。"
}error— the one-line string the webui composer shows in its error banner. It is a user-facing product string and is Chinese, like the rest of the chat surface; do not parse it.reason— the machine-readable discriminator.unknown_commandis the only reason this route produces itself. A declinedauthorize("slash.clear")gate does not fail the request:handleCmdCommandappends● 已取消 /<cmd> (授权未通过: <decidedBy>)to the transcript and returnshandled:true, so the answer is200 {ok:true, cmd}with nothing mutated. A failure in the gate, the write-ahead audit (fail-closed by design), or a command body answers5xx; the shared request gates can reject before the handler runs with403(untrustedOrigin, bad token) or429(rate limit).knownCommands— the accepted set, so a client never has to keep its own copy of the list.suggestion— for a/api/sendcommand such as/goal, the body says so explicitly ("send it as a normal message") instead of calling it unknown.
An earlier revision of this endpoint documented "the server sends the
command to mcode"; that was never true of the route, and the response
was written before the dispatch, so an unclaimed command answered
200 {ok:true} and the input was lost.
List webui sessions + mcode sessions (merged, deduplicated).
Response 200
{
"ok": true,
"count": 12,
"sessions": [
{ "id": "uuid", "title": "…", "workspace": "C:\\…", "mcodeSessionId": "mvs_…", "updatedAt": 1234567890 }
]
}Create a new webui session. Optionally tied to a workspace.
Request
{ "workspace": "C:\\path\\to\\project" }When workspace is provided it must clear the same containment gate as
POST /api/workspace (existing directory inside an allowed root, symlinks
resolved) — 400 otherwise, and no session record is created.
Response 200 {ok: true, session: <the full session record>}
The whole record comes back, not just an id — the client renders the new row
from it without a follow-up fetch. id, title, workspace, mcodeSessionId,
chat, createdAt, updatedAt, and (once set) titleCustom.
Switch to an existing session. Loads its chat history and (if linked) re-attaches to the mcode session.
Request
{ "id": "uuid" }id accepts a webui uuid or an mvs_… engine id.
Response 200
{
"ok": true,
"session": { "id": "uuid", "mcodeSessionId": "mvs_…", "title": "…", "chat": ["› …", "● …"] }
}The chat is returned here because switching is a navigation the client must render immediately, before the next SSE push arrives.
Rename a session (CRUD "update"). id accepts a webui uuid, an mvs_…
mcode session id, or a bare mvs_… with no webui wrapper yet (an overlay
record is created to carry the title). The title is user-authoritative: the
record is flagged titleCustom: true and mcode's automatic title generation
never overwrites it afterwards.
Not gated by the authorize() modal — renaming is non-destructive and
reversible (same class as session.create); a session.rename audit event
(from → to) is appended to the hash chain either way.
Request
{ "id": "uuid", "title": "my renamed session" }Response 200
{ "ok": true, "session": { "id": "uuid", "mcodeSessionId": "mvs_…", "title": "my renamed session", "titleCustom": true } }Errors — 400 missing id / blank title / title over 200 chars;
404 unknown non-mvs_ id.
Delete mcode sessions that no webui session references. There is no request
body and no scope parameter — the route only ever deletes orphans, and
never the currently-active session.
?dryRun=true previews without side effects (and without the authorize gate,
since nothing is touched). The real path is gated by
authorize("sessions.cleanup-orphans") and audited
(sessions.cleanup-orphans.intent before any delete, .done after).
Request — no body. Optional query: ?dryRun=true
Response 200 (preview)
{ "ok": true, "dryRun": true, "count": 18, "ids": ["mvs_5103ca…", "mvs_88c796…"] }Response 200 (executed)
{
"ok": true,
"dryRun": false,
"deleted": 18,
"failed": 0,
"deletedIds": ["mvs_5103ca…"],
"failedItems": [{ "id": "mvs_…", "status": 500, "reason": "…" }],
"decidedBy": "user",
"decidedAt": 1730000000000
}Response 200 (nothing to do) {ok: true, dryRun: false, deleted: 0, ids: []}
— returned before the authorize gate, since there is nothing to approve.
Response 403 {ok: false, error: "authorize declined", decidedBy, decidedAt}
If the .done audit append fails the route answers 5xx even though some
deletes already ran: the operator must see the audit gap rather than a silent
200.
Delete a webui session AND its linked mcode session (if any). The mcode
deletion is a single SQLite transaction across the 32 session-keyed
local_runtime_* tables (plus local_runtime_sessions) — any
non-absent per-table error rolls the whole transaction back.
Pass ?dryRun=true to preview the mcode-side impact without modifying
anything.
Response 200 (main path)
{
"ok": true,
"deleted": "uuid",
"matchKind": "webuiId",
"dryRun": false,
"remaining": 11,
"mcodeDbDel": {
"ok": true,
"outcome": "deleted",
"log": ["local_runtime_sessions:1", "local_runtime_message_rows:42", "…"],
"totalRowsDeleted": 57,
"tablesAbsent": 0
}
}mcodeDbDel.outcome — explicit verdicts (no fake success):
| outcome / reason | Meaning |
|---|---|
deleted |
Transaction committed; rows were removed. |
already_absent |
Transaction committed; nothing matched for this sid (tables individually missing are skipped only when the schema catalog confirms the absence). |
unsupported_schema (ok:false, reason) |
A table exists but has no session_id key column; rolled back, table names it. |
db_error (ok:false, reason) |
Lock conflict (SQLITE_BUSY/LOCKED), prepare/run failure, or IO error; rolled back. |
audit_write_failed (ok:false, reason) |
Rows may be gone but the audit event could not be recorded — surfaced, never a clean success. |
The session.delete audit event carries the outcome
(tablesAffected / tablesAbsent / totalRowsDeleted). On the orphan
path (mvs_* id with no webui session), a failed mcode delete answers
500 with the same mcodeDbDel failure object embedded; on the main
path the 200 body's mcodeDbDel.ok / outcome fields are the source
of truth for the mcode-side result.
Response 404 {"ok": false, "error": "session not found"} — id
matches neither a webui session nor an mvs_* pattern.
Four endpoints behind the session row's right-click menu (归档 / 置顶 /
复制为新会话). They are grouped here because they share one facade
(server/engine/session-context-actions.js) and one error vocabulary; the
individual sections follow.
| Endpoint | Body / query | What it does |
|---|---|---|
POST /api/sessions/:id/archive |
{ "archived": boolean } |
Archive or unarchive one session. |
POST /api/sessions/:id/pin |
{ "pinned": boolean } |
Pin or unpin one session. |
GET /api/sessions/:id/fork-options |
?assistantMessageId= |
Describe what a duplicate would be, before making one. |
POST /api/sessions/:id/fork |
{ "assistantMessageId"? } |
Duplicate the conversation as a new session in the same workspace. |
Sets the engine's archived flag on one session. One method covers both
directions (lifecycle-application.ts#archiveSession reads
req.archived !== false), so archived selects the direction rather
than separating two endpoints. The archived-tasks settings page restores a
row by calling this same endpoint with archived: false.
archived defaults to true and is not type-checked, deliberately: the
engine's own rule is "anything that is not literally false archives", and
a validator that disagreed with it would make the HTTP layer and the engine
answer differently about the same request.
The sidebar reads WHERE archived = 0, so an archived session leaves the
tree on the next read and an unarchived one returns. The response also
invalidates the tree cache, so the change is visible immediately rather than
after the 15 s TTL.
Not gated by the authorize() modal, unlike DELETE /api/sessions/:id:
archive is reversible — the conversation is intact on the engine and the
same call restores the row. A confirm dialog on a reversible action is a
dialog the user learns to dismiss.
Request { "archived": true } — archived optional, defaults to true.
Response 200 { "ok": true, "id": "mvs_…", "archived": true }
Errors — 400 missing id; 501 the provider cannot archive
(engine_capability_not_supported); 502 the engine was asked and failed
(engine_archive_failed); 503 no runtime booted
(engine_host_unavailable).
Pins or unpins one session through the engine's PinService, and returns
the engine's whole pinned set in its own order so a client can re-render
without a second tree read.
pinned is required and is not defaulted. The engine's
pinSession(sessionId, pinned, insertIndex?) takes the flag positionally
and branches on it, so {} is not a defaultable request: defaulting it
would move the row in a direction the user did not choose. This asymmetry
with archive is the engine's, not the route's.
This endpoint is the family's only member read through the PB-8
host-services window rather than through host.cliService, and it is
therefore the only one whose absence cases are all three distinct:
| State | code |
Status | Meaning |
|---|---|---|---|
| no runtime booted | engine_host_unavailable |
503 | The process is not running its runtime. |
| host with no owner graph | engine_services_unavailable |
501 | This transport carries no V2 services. |
owner graph without pinService |
engine_member_unavailable |
501 | The member this endpoint needs is not composed. |
None of the three produces a success payload. A pin that "succeeded" without writing anything is the fake-success shape this repository refuses.
Request { "pinned": true }
Response 200
{ "ok": true, "id": "mvs_…", "pinned": true, "pinnedIds": ["mvs_a", "mvs_b"] }Errors — 400 missing id or non-boolean pinned; 501 / 503 as tabled
above; 502 the engine was asked and failed (engine_pin_failed).
Answers "what would duplicating this session be?" before the user commits to
one. The duplicate dialog is this read, rendered: the engine's own canFork
decides whether the confirm button is live, and its suggestedTitle is shown
so the user sees the title the fork will use.
?assistantMessageId= narrows the preview to a fork POINT. Omitted, the
preview describes forking the whole conversation — which is what the menu
item means when the user has not picked a message.
Every field is present on every answer, so a partially-shaped engine response
renders an empty row rather than undefined. The worktree triple is
carried through untouched and unread: the worktree variant of this menu
has no desktop reference to build against, so it stays an honest placeholder
and the fields travel for the batch that unblocks it.
Response 200
{
"ok": true,
"id": "mvs_…",
"canFork": true,
"unavailableReason": null,
"suggestedTitle": "Copy of Refactor the parser",
"nextForkOrdinal": 2,
"sourceTitle": "Refactor the parser",
"worktree": { "visible": true, "eligible": false, "unavailableReason": "not a git repository" }
}A read that fails is propagated, not turned into canFork: false. A
default-shaped options object would put "canFork: false" on screen as if the
engine had refused a fork it was never asked about; the client has to be able
to tell "the engine said no" from "we could not ask".
Errors — 400 missing id; 501 / 503 as above; 502
(engine_fork_options_failed).
Duplicates the conversation as a new session in the same workspace.
Two fields are forced server-side and cannot be set by the caller:
useSuggestedTitle: true— the fork's title comes from the engine's own suggestion. The dialog SHOWS that suggestion, so the user sees exactly the title that will be used. A caller-suppliedtitleis dropped: this endpoint has one title contract.createIsolatedWorktree: false— the worktree variant of the menu is a placeholder, so the reachable action must be unable to create a worktree the user was never asked about.
clientRequestId is the engine's fork-deduplication key and is minted per
request by the route, so a duplicate delivery of one POST does not create two
forks while a user who genuinely duplicates twice gets two sessions.
assistantMessageId is the fork POINT and is forwarded only when named; its
absence is meaningful ("duplicate everything"), so the key is omitted rather
than sent as undefined.
Request { "assistantMessageId": "msg_…" } — both body keys optional.
Response 200
{ "ok": true, "id": "mvs_new…", "sourceId": "mvs_…", "forkOriginMessageId": "msg_…" }A resolved call that reported no session answers 502
engine_fork_no_session rather than a success with a null id — a success
with no id would leave the user watching a list grow by one row with no way
to name it.
Errors — 400 missing id; 501 / 503 as above; 502 (engine_fork_failed,
engine_fork_no_session).
复制到新工作树stays an honest placeholder with no route. The engine method exists (createIsolatedWorktree) andfork-optionsalready reportsworktreeVisible/worktreeEligible/worktreeUnavailableReason; what is missing is a reference — no desktop screenshot of that menu's UI exists. Inventing the dialog's shape, whether a branch is chosen, and what happens to the source session are all unknown.- Project-level
归档对话stays an honest placeholder with no route. The engine archives ONE session per call and declares nothing project-wide, so the item would have to fan out N single-session writes. Whether a partial failure counts as success, and whether the user authorizes once or N times, are product decisions no existing contract answers.
Raw mcode session list (from sqlite). No webui merge.
Response 200 {ok: true, sessions: [...]}
Get the title of an mcode session.
Response 200 {ok: true, title: "…"}
Sidebar tree: workspaces with their sessions nested. Cached for
15 s (CACHE_TTL_MS in server/routes/sessions.js). Mutations
(POST /api/sessions, /api/sessions/rename, DELETE /api/sessions/:id,
and the context actions /api/sessions/:id/archive,
/api/sessions/:id/pin, /api/sessions/:id/fork) bust the cache
automatically; clients that race the bust can pass ?refresh=1 to force a
reread.
The pin overlay. Every session carries a pinned boolean and the
payload carries a pins object saying how that boolean was resolved. The
tree itself is read from the runtime db (lib/session-tree.js), and
local_runtime_sessions has no pin column — the pin state lives in the
preference store the engine's PinService owns. So the tree is read first
and the pins are laid over it: pinned sessions move to the top of their
directory in the engine's own pin order, and unpinned sessions keep the
tree's updatedAt order among themselves. An empty pin set therefore leaves
the payload's ordering byte-identical, which is what stops an unpinned
sidebar from shuffling on every read.
pinned is written on every session, false included, so a client
reads a boolean and never has to know the id set exists. pins.degraded is
what distinguishes "nothing is pinned" from "the engine could not be asked":
when degraded is true every pinned is false because the answer is
unknown, and the sidebar renders the tree it already has. That read
degrades by design — failing the page's primary data source over a pin
service that would not answer would be a worse failure than not showing
pins. The four context-action endpoints do not degrade; a mutation that
cannot confirm its write never claims success.
The pin read never boots a runtime. It reaches the host through
peekEngineCatalogueHost(), which returns the host only if one is already
up. The ordinary getEngineCatalogueHost() is a booting getter — its
first call constructs the whole runtime, which costs seconds — and a read
that used it would turn the first tree request of a fresh process into a
boot, charging the cost to whoever painted the page first while looking
like an ordinary slow request. The rule: a write may boot what it needs; a
read may only use what is already there. The four endpoints above are
writes and do boot; the overlay does not.
Response 200
{
"ok": true,
"tree": [
{
"dir": "C:\\path\\to\\project",
"name": "project",
"current": true,
"sessionCount": 3,
"lastActiveAt": 1730000000000,
"sessions": [
{ "id": "uuid", "title": "…", "mcodeSessionId": "mvs_…", "updatedAt": 1730000000000 }
]
}
]
}Cross-workspace fuzzy title search, deduped per workspace (best match
wins). limit is clamped to [1, 100]. An empty q returns
{ok: true, results: []} by design — search is a query, not a list-all
endpoint. Gated by the per-request authorize path (action
session.search).
Response 200
{
"ok": true,
"results": [
{ "id": "uuid", "title": "refactor the workspace picker", "workspace": "C:\\…", "updatedAt": 1730000000000, "matchScore": 100 }
]
}Response 403 {ok: false, error: "authorize declined", decidedBy, decidedAt}
Export a session's chat as Markdown or JSON. Reads
$WEBUI_DATA_DIR/sessions.json (primary) + runtime-state.sqlite
(best-effort secondary). Gated by the per-request authorize path
(action session.export). The default format is md; download=true
attaches a Content-Disposition so the browser saves it.
Response 200 — format=md → text/markdown; charset=utf-8 body with the
chat rendered as Markdown.
format=json → application/json:
{
"ok": true,
"session": { "id": "uuid", "title": "…", "workspace": "C:\\…", "createdAt": 0, "updatedAt": 0, "mcodeSessionId": "mvs_…" },
"messages": [{ "role": "user", "content": "…" }],
"_meta": {
"source": "merged",
"exportedAt": 1730000000000,
"messageCount": 2,
"mcode_unavailable": false
}
}The conversation is under messages, not chat — it is a merged,
role-tagged list (webui transcript + engine rows), which is a different shape
from the chat string array kept in the session store. _meta.mcode_unavailable
reports whether the engine side could be read; when it is true,
mcode_unavailable_reason says why.
Errors — 400 missing id / unsupported format (with allowed list);
403 authorize declined; 404 unknown id.
Change the workspace for the current CID.
Request
{
"dir": "C:\\path\\to\\project",
"syncTui": true
}dir(string, required) — absolute pathsyncTui(bool, optional) — also write the path tocwd.jsonso the mcode TUI sees itaction: "detect"— instead of changing, return the current TUI cwdaction: "useTui"— copy the TUI's cwd to webuiaction: "reset"— restore webui's default workspace
Response 200
{
"ok": true,
"workspace": { "dir": "C:\\path\\to\\project", "branch": null, "tree": null },
"tuiCwd": "/home/you/projects/foo",
"defaultWorkspace": "C:\\Users\\you\\.mcode-webui\\webui"
}The current workspace is nested under workspace, not flattened to the top
level. branch and tree are null — the server does not shell out to git,
and a previous revision of this document claimed "main" / "clean", which
were never measured.
List a directory for the tree browser.
Request query: ?path=C:\\Users (omit for drive roots on Windows
or / for Linux)
Response 200
{
"ok": true,
"path": "C:\\Users",
"children": [
{ "name": "Public", "path": "C:\\Users\\Public", "isDir": true }
]
}When path is omitted, the root view lists only the allowed roots
(🔒 v2 — see below); the response keeps its platform-compatible shape:
- Windows:
roots: ["C:\\Users\\you", …](the allowed roots),dir: null - POSIX:
dir: "/",roots: ["/home/you", "/tmp", …],children: []
🔒 v2 workspace containment (PR #55 review point 5): a candidate
path is resolve()d and symlink-resolved (realpath), and must land
within an allowed root — default allowed roots are the user's home
directory + the default workspace + the system tmp directory. The
MCODE_WEBUI_WORKSPACE_ROOTS env var (path-separator-separated
segments) fully replaces the default set. Out-of-root paths —
including ../ traversal and symlink escapes — are rejected with an
actionable error naming the resolved path, the allowed roots, and the
env knob. Both this endpoint and POST /api/workspace enforce the same
boundary.
Workspace → session tree, used by the sidebar's workspace dropdown
and the Switch Workspace sheet. Groups the webui sessions store by
workspace dir; sorts by current first, then lastActiveAt desc.
The currently-active workspace appears at the top even when it has
zero sessions (the most common choice when starting a new chat).
Response 200
{
"ok": true,
"current": "C:\\path\\to\\project",
"defaultWorkspace": "C:\\…\\webui",
"home": "C:\\Users\\you",
"tmpDir": "C:\\Users\\you\\AppData\\Local\\Temp",
"platform": "win32",
"workspaces": [
{
"dir": "C:\\path\\to\\project",
"name": "project",
"sessionCount": 3,
"lastActiveAt": 1730000000000,
"current": true,
"sessions": [
{ "id": "uuid", "mcodeSessionId": "mvs_…", "title": "…", "updatedAt": 1730000000000 }
]
}
]
}Resolve a folder name (the only thing <input webkitdirectory> gives
the browser) into absolute-path candidates across the common roots
(home, default workspace, tmp). The user confirms which one matches.
The server runs on the same machine as the browser, so a name lookup
is enough — no permission prompt needed.
Response 200
{ "ok": true, "candidates": ["C:\\path\\to\\folder", "/home/you/folder"] }Recent workspaces, optionally filtered by a substring match against
the dir. limit is clamped to [1, 20]; default is 5. The tmpDir
field on the response is for the "no workspace needed" button.
Response 200
{
"ok": true,
"items": [{ "dir": "C:\\…", "name": "project", "lastActiveAt": 1730000000000, "sessionCount": 3 }],
"total": 12,
"search": "",
"limit": 5,
"tmpDir": "C:\\Users\\you\\AppData\\Local\\Temp"
}The fs endpoints are read/write primitives for the workspace picker
panels. They share the same containment boundary as
POST /api/workspace / GET /api/workspace/browse: a candidate path
is resolve()d, symlink-resolved (realpath), and must land within
an allowed root (default home + default workspace + tmp;
MCODE_WEBUI_WORKSPACE_ROOTS fully replaces the set).
GET /api/fs/read?path=<dir>&showHidden=0|1
List a directory inside an allowed root. path is required;
showHidden=1 includes dotfiles. Symlinks-as-files are returned as
Dirent entries (webui shows them as files; no symlink-follow yet —
see CAPABILITIES.md §6).
Response 200 (the shape comes from readDirectory())
{ "ok": true, "path": "C:\\Users\\you\\Documents", "entries": [{ "name": "…", "path": "C:\\…", "isDir": true }] }Errors — 400 missing path; 403 out-of-root.
Create a directory inside an allowed root. The target does not have to exist yet — the route validates the parent path is in-bounds before creating.
Request
{ "path": "C:\\Users\\you\\Documents\\new-folder" }Response 200 {ok: true, path: "C:\\…\\new-folder"}
Errors — 400 invalid JSON; 403 parent out-of-root; 409 already exists.
Read the contents of a single regular file as text. Drives the right-panel
file preview (slice 02 — webapp/components/file-preview.tsx). Same
containment boundary as /api/fs/read; the gate runs first, so an
out-of-root path is rejected before the file is even stat'd.
Files over 512 KiB are rejected with 413 rather than silently
truncated — the caller (the webapp preview) renders a "too large" state
and points the user at a real editor. The body still carries the file's
detected mime / language so the UI can route it to the right
renderer without a second round-trip.
Binary detection scans the first 4 KiB for a NUL byte. A binary file is
returned with ok:false, error:"binary file not supported" and a 415
status; the webapp renders an "无法预览" placeholder. The error path
still carries mime / language so the UI can hint at why (e.g.
"image, use the raw endpoint" for .png).
Response 200
{
"ok": true,
"path": "C:\\Users\\you\\README.md",
"size": 2400,
"mtime": 1790609123912.887,
"mime": "text/markdown; charset=utf-8",
"language": "markdown",
"binary": false,
"encoding": "utf-8",
"content": "# Title\n\n…"
}encoding is "utf-8" on success (with the BOM stripped); language is
one of markdown / typescript / javascript / json / yaml / css
/ html / python / go / rust / bash / sql / dockerfile /
plain (informational — the renderer is allowed to ignore it). mtime
is the file's stat().mtimeMs at read time (slice 27): the preview
editor records it together with size as the conflict-detection baseline
and sends both back on save — POST /api/fs/write answers 409 when
the disk has moved on in the meantime.
Errors — 400 missing path; 403 out-of-root; 403 {code:"credential"}
on a credential-shaped basename (unless ?confirm=1); 413 over the 512 KiB
cap; 415 binary file or non-regular file (directory / device / socket);
500 stat failure (file vanished mid-request).
Credential predicate is name-based — hardlink aliasing is NOT covered.
classifyCredential (server/lib/credential-file.js, mirrored verbatim in
webapp/lib/credential-file.ts) compares the basename of the request
path against the credential shape table. The defence therefore covers
symlinks (resolved by realpathSync before the read) but not hardlinks —
two names that share an inode (config.txt → .env) are indistinguishable
by basename, since the kernel does not expose the "primary" name from
the inode alone. Operators concerned about hardlink aliasing must keep
the workspace tree uncluttered. The same predicate is reused in streaming
form by /api/fs/raw and is re-applied by /api/fs/search (where
matches are flagged credential: true but never content-stripped).
Stream raw bytes for a file. Used by <img> and download affordances in
the preview (slice 02). Same containment boundary as /api/fs/read;
20 MiB hard cap (matches the pr-22 reference).
Content-Type is mapped from the extension; unknown extensions fall
through to application/octet-stream. Cache-Control: no-store — local
files have no immutable hash, the cache must not lie about freshness.
Response 200 — binary stream. Examples:
| extension | Content-Type |
|---|---|
.png / .jpg / .jpeg / .gif / .webp / .ico / .pdf |
as listed |
.svg |
image/svg+xml |
.html / .htm / .css / .js / .mjs / .json / .md / .txt |
text/...; charset=utf-8 |
.woff2 |
font/woff2 |
| (anything else) | application/octet-stream |
Errors — 400 missing path; 403 out-of-root; 404 not found; 400 not
a regular file; 413 over the 20 MiB cap.
The preview toolbar's save button lands here. This is the ONLY write surface the file preview opens, and every boundary below is enforced server-side — the webapp is a presenter over the structured answer.
Request
{
"path": "C:\\Users\\you\\README.md",
"content": "# Title\n\nedited in the preview panel\n",
"expectedMtime": 1790609123912.887,
"expectedSize": 2400,
"confirm": false
}| Field | Required | Meaning |
|---|---|---|
path |
yes | absolute path (or ~/...); through the SAME safePath → assertWorkspacePath gate as every other /api/fs/* route — realpath-resolved, symlink-aware, out-of-root = 403 |
content |
yes | the full file body as a UTF-8 string; non-string = 400 invalid-content |
expectedMtime |
no | the mtime GET /api/fs/read-file returned when the file was opened |
expectedSize |
no | the size from the same read |
confirm |
no | true = the user passed the credential confirmation card (see below) |
Conflict detection. When either baseline field is present and no
longer matches the live stat, the route answers 409 and writes
NOTHING — an external edit must surface as a conflict the user resolves,
never a silent overwrite. A body with NO baseline fields is the
explicit-overwrite shape; the panel only sends it after the user
answered the conflict card ("覆盖磁盘版本").
Credential guard (slice 16 alignment). Credential-shaped basenames
(.env / *.pem / id_rsa / credentials* / … — the same
classifyCredential predicate the read routes use) default-refuse with
403 {code:"credential", credentialReason} and the file is untouched.
confirm:true releases the write AND emits the same credential.override
stderr audit line as the read override, with endpoint:"write". Rationale:
the server broadcasts a LAN URL, and a web-editable .env makes every
LAN peer an author of the local machine's config.
Controlled write. The handler is a bare writeFileSync(path, content, 'utf8') on the gated path — no shell, no exec, no command interpolation
anywhere on this path. The editor edits EXISTING files only; there is no
create-through-the-web path.
Response 200 — the fresh baseline the next save should conflict-check against:
{
"ok": true,
"path": "C:\\Users\\you\\README.md",
"size": 40,
"mtime": 1790609400000.5
}path is the realpath-normalised absolute form (the shared gate
resolves symlinks before anything else — the same slice-16 form every
/api/fs/* route returns; on macOS, a write to /var/folders/…
answers /private/var/folders/…).
Errors — 400 missing-path / missing-content / invalid-content /
not-a-regular-file; 403 out-of-root (shared gate; a missing path
normally fails containment here with the realpath error — the read route
documents the same behaviour); 403 credential (unconfirmed credential
shape); 404 not-found (file vanished between gate and stat — TOCTOU
guard); 409 conflict ({diskMtime, diskSize} on the body); 413
too-large (content over WRITE_MAX_BYTES = the read's 512 KiB — you
cannot save what you could never have loaded); 413 BODY_TOO_LARGE
(JSON body over the shared 1 MiB reader cap); 500 write-failed (the
writeFileSync itself threw — e.g. EACCES; the disk file is untouched).
Credential predicate is name-based — hardlink aliasing is NOT covered,
exactly as documented under GET /api/fs/read-file.
Hands path to the platform's default opener (open / xdg-open /
cmd / Start-Process). Containment gate is the same assertWorkspacePath
- per-node realpath check used by
/api/fs/read; the route's job is to JSON-decode the body and map the helper's structured codes to HTTP status.
Request
{ "path": "/home/you/repo/README.md" }Response 200 { ok: true }
Errors (sourced from routes/fs.js#codeToStatus):
400 {code:"missing-path"}— nopathin body403 {code:"out-of-bounds"}— containment rejected400 {code:"not-a-regular-file"}— directory / non-existent / symlink escape503 {code:"no-opener"}— host has no GUI binary onPATH; the UI disables the button on this answer so a click never silently no-ops502 {code:"spawn-failed"}— binary ENOENTed between probe and exec
Same wire model as /api/fs/open-default; macOS / Windows select the file's
row, Linux opens the parent directory (no portable "select" command exists
under freedesktop).
Request
{ "path": "/home/you/repo/README.md" }Response 200 { ok: true }
Errors — identical code → status map to open-default.
The git endpoints drive the right-panel Git panel (slice 03 —
webapp/components/panels.tsx#GitPanel) and the /review slash
command. They share the same containment boundary as the fs endpoints
(/api/fs/*): a candidate dir is resolve()d, symlink-resolved
(realpath), and must land within an allowed workspace root (default
home + default workspace + tmp; MCODE_WEBUI_WORKSPACE_ROOTS fully
replaces the set). Out-of-root directories are answered with a
{ok:false, error:"…不在允许根内…"} payload — the panel surfaces
that as an empty state rather than as a red toast.
Security invariants (pinned by test/routes/git.test.js):
gitis invoked throughexecFilewith['-C', dir, ...args]— no shell, no metacharacter surface.gitCheckoutmatches the branch name against^[A-Za-z0-9._/-]+$and additionally rejects names that start with-(a branch named--upload-pack=…would otherwise be re-interpreted as agit checkoutoption by the binary itself).gitDiffalways passes the user-supplied file after a--token, so a filename like--output=/etc/xcannot be re-interpreted as agit diffoption. The same input is rejected up front by an explicitstartsWith('-')guard.
GET /api/fs/search?root=<dir>&q=<glob>[&depth=&maxNodes=&wallMs=&limit=&includeHidden=1]
Bounded workspace-wide search by basename glob (slice 19a). The shipped
file-tree filter matches names only against already-expanded nodes, so
a package.json three directories deep shows nothing until the user
manually expands every intermediate directory. This endpoint walks the
workspace behind the same assertWorkspacePath gate the other
/api/fs/* routes use, with hard budgets so a hostile or pathological
request cannot pin the server.
The panel calls this endpoint when the user types into the filter
box and the in-memory tree has no match. Each call is a single
round-trip that returns every match under root in one response; the
panel "expands to the match" by walking the ancestors chain it gets
back.
Query parameters — root and q are required; every other
parameter is optional and bounded by an absolute upper limit (an
out-of-range value is clamped, not rejected):
| Param | Default | Max | Notes |
|---|---|---|---|
root |
— | — | absolute path or ~/.... Goes through assertWorkspacePath; out-of-root = 403. Must point at a directory. |
q |
— | — | glob; * any run, ? one char, case-insensitive, anchored. Empty = 400. |
depth |
8 | 16 | max directory depth from root. Exceeded → truncated: true, truncatedReason: "depth". |
maxNodes |
5000 | 50000 | number of visited entries (files + dirs). Exceeded → "nodes". |
wallMs |
1500 | 5000 | wall-clock cap in ms. Exceeded → "wallClock". |
limit |
200 | 1000 | max matches returned. (Alias maxMatches also accepted.) Exceeded → "matches". |
includeHidden |
0 | — | 1 to include dotfile entries; default mirrors the file tree's hidden-by-default behaviour. |
The walker skips these directories by default (node_modules /
.git are non-overridable; the build/cache set can be opted back
into with the includeDirs option server-side):
| Skip reason | Default on? | Notes |
|---|---|---|
node_modules |
yes (non-overridable) | canonical search stall at every JS project |
.git |
yes (non-overridable) | privacy surface; never the user's intent |
dist / build / .next / .cache / .parcel-cache / .turbo / .nx / coverage / .svn / .hg / .idea / .vscode |
yes (overridable server-side) | build outputs & VCS metadata, each a known walker trap |
| huge dir (> 10 000 readdir entries) | yes | per-directory entry count, not bytes |
| credential-shaped names | flagged, never omitted | see "credential decision" below |
Response 200
{
"ok": true,
"root": "/home/you/文档/demo002",
"q": "package.json",
"matches": [
{
"path": "/home/you/文档/demo002/codersday/package.json",
"name": "package.json",
"type": "file",
"ancestors": ["codersday"],
"credential": false
}
],
"scanned": { "dirs": 12, "files": 47, "total": 59 },
"skipped": {
"node_modules": 1,
".git": 0,
"credential": 0,
"huge": 0,
"optional": { "dist": 0, "build": 0, ".next": 0 }
},
"truncated": false,
"truncatedReason": null,
"elapsedMs": 7,
"budgets": { "maxDepth": 8, "maxNodes": 5000, "wallMs": 1500, "maxMatches": 200, "includeHidden": false, "includeDirs": [] }
}ancestors is the path components between root (exclusive)
and the match (exclusive); for a top-level match it is [] so
the client can use path directly. The walker NEVER returns
file contents — matches[i] has path / name / type / ancestors plus the optional credential flag and nothing
else. No size sample, no mtime sample, no preview metadata.
Truncation honesty. A response with truncated: true is the
walker's explicit "I didn't finish" signal. The reasons are pinned:
"depth" | "nodes" | "wallClock" | "matches". The UI shows
searched N, skipped M, truncated by <reason> so the user knows
the displayed list is partial.
Credential decision — flagged, never omitted, never read.
classifyCredential (the slice-16 predicate in
lib/credential-file.js) is the single source of truth. A
credential-shaped match is included with credential: true
plus a stable credentialReason (one of dotenv / key-file /
ssh-key / credentials / ssh-meta), AND skipped.credential
is incremented. The rationale:
- The user has the right to know the file exists (mirrors
/api/fs/read, which keeps credentials visible in the tree listing). - The path is the realpath form; a user-initiated click on the
match lands on
/api/fs/read-file, whose slice-16 gate refuses by default with the samecode: "credential"answer the right panel already speaks. - Omitting the match would make a search for
q=*.env(orq=.env) return zero rows — actively misleading because the workspace DOES contain those files. - The response never carries content (or size / mtime / any preview metadata), so the search cannot itself become a credential leak even when the user is looking for one.
Errors — 400 missing root / q / root is not a directory;
403 out-of-root (same message as the other /api/fs/* routes);
the gate runs first, so a malformed root is refused before the
walker runs.
Workspace status for the panel header and the conversation toolbar's
version badge. dir is required.
status --porcelain=v1 -b gives a deterministic stream: one header
line (## <branch>[...<upstream>] [ahead N, behind M]) followed by
the per-file entries. The route parses both halves; a detached HEAD
or a branch with no upstream simply produces a null upstream /
zero ahead/behind without an error. A second git log -1 runs
concurrently against the same already-gated dir for the two HEAD
identity fields below, so the route still costs one git round trip
in wall-clock terms.
Response 200
{
"ok": true,
"isRepo": true,
"branch": "feat/git-panel",
"upstream": "origin/feat/git-panel",
"ahead": 0,
"behind": 0,
"headSha": "0e99b45",
"headCommittedAt": "2026-10-01T09:12:33+08:00",
"files": [
{ "x": "M", "y": " ", "path": "README.md", "origPath": null, "staged": true },
{ "x": "?", "y": "?", "path": "untracked.txt", "origPath": null, "staged": false }
]
}x / y are the raw porcelain status codes (see git status --help
§ "porcelain v1 format"); staged is x !== ' ' && x !== '?'
(includes M, A, D, R, C in the index position). Renames
carry origPath (the pre-rename path) alongside path (the new
path). isRepo:false answers a non-git directory without an error.
| Field | Meaning |
|---|---|
headSha |
git log -1 --format=%h — the abbreviated commit id at git's own abbreviation length (7 by default, longer where 7 would be ambiguous). Consumers must not hard-code 7. |
headCommittedAt |
git log -1 --format=%cI — HEAD's committer time, strict ISO 8601. Committer, not author: a rebase, amend or cherry-pick moves the committer time forward while the author time stays at the original write, so the author time would report a freshly rebased branch as months old. |
A repository with an unborn HEAD (git init, nothing committed yet)
answers ok:true, isRepo:true with headSha: null and
headCommittedAt: null — the workspace is a healthy repository, it
simply has no commit to name. A directory that is not a repository at
all omits both fields entirely, and so does a dir the containment
gate refuses, which lets the toolbar's version badge treat "absent or
null" as a single "render nothing" case.
A detached HEAD still reports headSha; branch is null and the
badge shows the sha alone.
Errors — 400 missing dir; the body is {ok:false, error} and
the status stays 200 (the panel reads ok rather than the HTTP
code, so a non-git directory is a normal state).
Local branches plus a current marker. The panel renders this list
as the branch switcher — gitCheckout requires the picked name to
match the same set, so the switcher never has a choice it cannot
honour.
Response 200
{
"ok": true,
"branches": [
{ "name": "feat/git-panel", "current": true },
{ "name": "main", "current": false }
]
}Errors — 400 missing dir; {ok:false, error} on git failure.
Single-file diff against HEAD. Untracked files (? in porcelain)
fall back to git diff --no-index -- /dev/null <file>, which
produces a synthetic all-add diff so the panel can preview them too.
The fallback returns {ok:true, diff} (never an error) when the
input file exists; an ok:false is reserved for the gate rejection
or for a git invocation failure.
Response 200
{ "ok": true, "diff": "diff --git a/README.md b/README.md\n…" }Errors — 400 missing dir/file; {ok:false, error} for
containment or invalid path. The HTTP status stays 200 for
soft-fail paths; the panel reads ok.
Switch to a local branch. Destructive — the panel gates the
button behind a confirmation prompt before sending. Server-side
defence in depth: the branch name is matched against
^[A-Za-z0-9._/-]+$ and rejected if it starts with -, so a
forged client cannot smuggle an option through.
Request
{ "dir": "C:\\Users\\you\\projects\\foo", "branch": "feat/git-panel" }Response 200 {ok:true} on success; {ok:false, error} on
gate / allow-list rejection or git failure. The HTTP status
stays 200; the panel reads ok.
Errors — 400 missing dir/branch, invalid JSON;
{ok:false, error:"非法分支名"} on allow-list rejection;
{ok:false, error} on git failure.
Plugin management (ticket 60, phase 1), served by
server/routes/plugins.js. Every handler reaches the
local-runtime-v2 plugin system through the catalogue host, which
starts lazily on the first plugin call; the routes hold no plugin
state of their own. The gate chain (CORS → origin/CSRF → LAN → token →
rate limit → read-only) is inherited from app.js exactly as for
/api/git/* — there is no second authentication path here. The
surface that calls these endpoints is plugins-surface.tsx, through
webapp/lib/api.ts.
Two answer conventions cover the whole group:
- A runtime failure is HTTP 200 with
{ok:false, error, code}. The panel branches oncode; a non-2xx status would misreport an expected state as a transport fault. When the catalogue host cannot boot, every endpoint answers{ok:false, error:"runtime unavailable", code:"RUNTIME_UNAVAILABLE"}. - A rejected request is an HTTP error: 400
{ok:false, code:"invalidBody"}for a missing or malformed parameter, 400 with the code intact for the three facade validation codes (INVALID_PLUGIN_SOURCE,PLUGIN_LIMIT_INVALID,PLUGIN_CURSOR_INVALID), 403 in read-only mode for every POST (gate 5 — the correct answer, not a bug), 413 for a body above 1 MiB.
source names the plugin origin and is numeric on the wire: 1 =
official (cloud registry), 2 = local (packages on this machine). It
is required on GET /api/plugins/marketplace, because the runtime
reads a missing source as "official" and a silent default would aim
every request at a registry the local edition cannot reach.
In responses the numeric source is passed through as the runtime
produced it, and the route additionally stamps a protocol-free
sourceKind string — "official" or "local" — on the page, on every
plugin row, and on every mutation answer. The webapp branches on
sourceKind, which is how it stays free of an @mavis/protocol
dependency (@mavis/webui does not have one). An element whose
source is neither 1 nor 2 gets sourceKind:"unknown".
Phase 1 covers the plugins domain only. skills, mcp, apps and
agents have no endpoints yet; the other four tabs of the panel
render a staged placeholder that says their management surface opens
in a later phase. The state of the surface is recorded in
docs/webui.md.
| func_name | Endpoint | Panel use |
|---|---|---|
plugins.list.installed |
GET /api/plugins/installed |
Installed list |
plugins.list.marketplace |
GET /api/plugins/marketplace |
Marketplace, one source per call |
plugins.list.enabled |
GET /api/plugins/enabled |
The plugins the current turn can use |
plugins.refresh.all |
POST /api/plugins/refresh |
Reconcile button |
plugins.enable.by_name |
POST /api/plugins/enable |
Card switch, on |
plugins.disable.by_name |
POST /api/plugins/disable |
Card switch, off |
plugins.install.by_name |
POST /api/plugins/install |
Official install |
plugins.uninstall.by_name |
POST /api/plugins/uninstall |
Delete, behind a confirmation |
plugins.import.preview_url |
POST /api/plugins/import/preview |
Import dialog, dry run |
plugins.import.from_url |
POST /api/plugins/import |
Import dialog, commit |
What is real and what is a placeholder. The installed list, the
local marketplace (source=2) and both GitHub import endpoints are
real data — the import path fetches a public repository directly
and never touches the cloud registry. The official marketplace
(source=1) and the official install / enable / disable / uninstall
actions are the only honest placeholders of this phase: the cloud
base URL does not resolve in the local edition, so the official listing
answers {ok:false} and the panel renders the
plugins.market.official.notLocal.* copy rather than an error toast.
The four non-plugin tabs render a staged placeholder of their own
(plugins.area.<domain>.pending.*) that says the management surface
opens in a later phase.
func_name plugins.list.installed. Installed plugins, official and
local segments merged, one page. keyword filters by name; limit
defaults to 50 and is capped at 200; cursor is the opaque forward
cursor from nextCursor.
Response 200
{
"ok": true,
"plugins": [
{
"name": "acme-notes",
"version": "1.2.0",
"displayName": "Acme Notes",
"description": "…",
"author": "acme",
"iconUrl": "https://…/icon.png",
"source": 2,
"sourceKind": "local",
"enabled": true,
"capabilities": { "appCount": 0, "mcpServerCount": 1, "skillCount": 3, "hookCount": 0 }
}
],
"hasMore": false
}The empty state is {ok:true, plugins:[], hasMore:false} — nothing
installed is an answer, not an error. hasMore:true carries
nextCursor. The panel shows skeleton rows while this is in flight.
Errors — 400 on a malformed parameter: a non-integer limit or a
category that is not an integer answers code:"invalidBody", and a
cursor issued for a different keyword answers 400 with
code:"PLUGIN_CURSOR_INVALID" (the panel drops the cursor and restarts
the list). Runtime failures answer 200 with their own code. Note that
the webapp helper turns any non-2xx into a thrown error carrying the
server's error text, which is why the panel resets the cursor when the
filter changes rather than on the failure itself.
func_name plugins.list.marketplace. source is required (§Plugins
above). category is a numeric category id (0 other … 10 education);
skillLimit / skillCursor page the standalone-skill segment.
Response 200
{
"ok": true,
"source": 2,
"sourceKind": "local",
"plugins": [
{
"name": "acme-notes",
"displayName": "Acme Notes",
"description": "…",
"installExists": false,
"enabled": false,
"category": 7,
"capabilities": { "appCount": 0, "mcpServerCount": 1, "skillCount": 3 },
"sourceKind": "local"
}
],
"hasMore": false,
"pluginTotal": 1,
"marketplaceSkills": [
{ "id": 41, "name": "weekly-digest", "displayName": "Weekly digest", "added": true }
],
"skillHasMore": false
}A marketplace summary carries no source of its own — the page is one
source — so the route stamps sourceKind on every row from the
requested source. The empty state is {ok:true, source, sourceKind, plugins:[], hasMore:false}. marketplaceSkills carries the standalone
skills the local branch projects alongside the plugin rows; it is
present for source=2 and the panel decides whether to interleave the
two. The official branch may additionally answer
cursorResetRequired:true, meaning the registry rejected the cursor
and the caller restarts from the first page.
Errors — 400 when source is missing or not 1/2, when limit
is not a positive integer, or when category is not an integer
(code:"invalidBody"); source=1 answers ok:false in the local
edition (unreachable cloud base URL) and the panel renders the
not-local placeholder for it. source=2 failures are ordinary errors
and surface as one.
func_name plugins.list.enabled. The plugins the current runtime
snapshot reports as enabled — narrower than the installed list, which
also carries disabled entries.
Response 200
{ "ok": true, "plugins": [{ "name": "acme-notes", "displayName": "Acme Notes" }] }The empty state is {ok:true, plugins:[]}.
Errors — 200 {ok:false, error, code} when the runtime is
unreachable; 400 is not possible (no parameters).
func_name plugins.refresh.all. Reconciles installed state against
both sources. No parameters; the request body is drained and ignored.
Response 200 {ok:true} — the answer carries no data, so the caller
re-pulls GET /api/plugins/installed afterwards. The panel shows a
spinner on the refresh button while it runs.
Errors — 200 {ok:false, error, code} with the runtime's own code;
403 in read-only mode.
Turn a plugin on. func_name plugins.enable.by_name.
Turn a plugin off; the plugin's turn hooks deactivate, and a session
already running on it is not interrupted. func_name
plugins.disable.by_name.
Install a plugin. Only the official source installs in the local
edition — a local package answers LOCAL_PLUGIN_INSTALL_UNSUPPORTED
and the panel never renders the button. func_name
plugins.install.by_name.
Uninstall a plugin. Destructive — the panel gates the button
behind a confirmation prompt, and uninstalling a target that is not
installed is idempotent rather than a failure. func_name
plugins.uninstall.by_name.
These four share one body and one answer shape.
Request
{ "pluginName": "acme-notes", "source": 2 }source is optional, and an omitted one is forwarded as-is — the
runtime reads a missing source as "official" one layer down, so a
caller that knows which side the plugin came from should pass it. A
pluginName that is missing or blank answers 400 invalidBody, as
does a source that is neither 1 nor 2. Uninstalling a target that is
not installed is idempotent, not a failure.
Response 200
{ "ok": true, "source": 2, "sourceKind": "local", "installExists": true, "enabled": false }installExists says whether the plugin is on disk; enabled is the
resulting state. The panel shows a row-level spinner for the duration
of the call.
Errors — PLUGIN_NOT_FOUND, PLUGIN_AUTH_REQUIRED and
PLUGIN_AUTH_SYNC_TIMEOUT as code on a 200 answer; 400 invalidBody
for a body the route will not read; 403 in read-only mode. The official
mutations are the placeholder half of this surface: PLUGIN_AUTH_REQUIRED
is the expected answer for them in the local edition, and the panel
stays silent rather than raising a toast.
func_name plugins.import.preview_url. Resolves a GitHub URL and
reports what importing it would bring, without installing anything. It
fetches the public repository directly — no cloud account, no registry.
Request
{ "url": "https://github.com/acme/mcode-plugin" }Response 200
{
"ok": true,
"source": { "repositoryUrl": "https://github.com/acme/mcode-plugin", "commitSha": "0f1e2d3" },
"plugin": {
"summary": { "name": "acme-notes", "displayName": "Acme Notes", "capabilities": { "appCount": 0, "mcpServerCount": 0, "skillCount": 2 } },
"skillCount": 2,
"mcpServerCount": 0,
"hasStdioMcp": false
},
"diagnostics": [{ "code": "SKILL_NAME_COLLISION", "capability": "skill", "name": "weekly-digest" }],
"packageSizeBytes": 18432,
"canImport": true
}source is the pinned coordinate to hand to the commit call;
canImport:false with populated diagnostics is a valid answer, and
the dialog shows them instead of an error. The panel shows a loading
state for the duration of the fetch.
Errors — 400 on a malformed body; 200 {ok:false, error, code} for
an invalid URL, a repository the public internet cannot reach,
PLUGIN_NO_SUPPORTED_CAPABILITY, or PLUGIN_IMPORT_UNAVAILABLE.
func_name plugins.import.from_url. Installs the plugin a preview
resolved; the answer carries the plugin summary, enabled.
Request
{
"source": {
"repositoryUrl": "https://github.com/acme/mcode-plugin",
"commitSha": "0f1e2d3",
"subPath": "packages/notes"
}
}subPath is optional and selects a plugin inside a monorepo.
Response 200
{ "ok": true, "plugin": { "name": "acme-notes", "displayName": "Acme Notes", "enabled": true, "capabilities": { "appCount": 0, "mcpServerCount": 0, "skillCount": 2 } } }Errors — PLUGIN_ALREADY_EXISTS when the plugin is already
imported, PLUGIN_IMPORT_INVALID for a coordinate the runtime cannot
use, both on a 200 answer; 403 in read-only mode.
The runtime records, per turn, the files that turn changed — with real added / deleted line counts and the ability to put the workspace back the way it was. These three endpoints expose that record. They exist because the transcript alone cannot answer the question: it carries the file paths a tool named, never the line counts, and never the engine's decision about whether a turn can still be changed.
| func_name | Endpoint | Card use |
|---|---|---|
sessions.diff.get_turn |
GET /api/turn-diff |
Read a turn's counts and gates |
sessions.diff.revert_turn |
POST /api/turn-diff/revert |
Undo button |
sessions.diff.reapply_turn |
POST /api/turn-diff/reapply |
Redo button |
Every request carries assistantMessageId: the msg_id of that turn's
last assistant message, which is the value the runtime persisted the
turn's record under. The webapp gets it from the transcript, where the
server writes it as a §§ turn_msg=<id> marker line at prompt finalise
(server/lib/mcode-acp.js#finalize) and the transcript backfill
synthesises from the runtime's own turn_id / msg_id columns
(server/lib/transcript.js).
assistantMessageId is mandatory on all three endpoints, and its
absence is not a client error — it is the "this turn has no coordinate"
case, answered with {"ok":true,"turnDiff":null} and no call to the
engine. The reason is the engine's own selector
(local-runtime/src/turns/diff-api.ts:209-220): given no id it falls
back to latestForSession, so a request that lost the coordinate would
answer with a DIFFERENT turn's counts, and the undo button would rewrite
that turn's files. A turn with no coordinate renders the path-only card.
The route reaches exactly one member of the runtime's applications
tree, applications.session.diff. The tree also carries
session.lifecycle, which can delete a session; widening this surface
to "the applications handle" would hand that to a diff endpoint.
func_name sessions.diff.get_turn. sessionId is the ENGINE's
session id (mvs_ + 32 hex), the one on state.mcodeSessionId.
Response 200
{
"ok": true,
"turnDiff": {
"fileChanges": [
{ "file": "webui-turn.txt", "additions": 2, "deletions": 0, "status": "added" }
],
"sourceMessageId": "ed8b9ddd-9bb0-4b06-a8fc-e863036830e3",
"changeSetId": "cs_ffc8873c6",
"status": "active",
"undoable": true,
"canUndo": false,
"canReapply": false
}
}canUndo / canReapply are the engine's own answer, and the card must
render its two buttons from them rather than deciding for itself. Only
the LATEST turn diff can be changed; the engine reports that as
canUndo:false before the user clicks, and refuses with 409 if they do.
additions / deletions are the turn's own before/after line counts,
not the workspace's diff against HEAD. previewState is declared by the
protocol but this path never fills it — do not render it.
Responses
200 {"ok":true,"turnDiff":null}— no coordinate on the request, or no record for that id (a turn that changed nothing never gets one).400 {"ok":false,"code":"invalidRequest","error":"sessionId must look like mvs_<32 hex> …"}— a malformedsessionId.404— the engine does not know that session.200 {"ok":false,"code":"RUNTIME_UNAVAILABLE"}— the runtime application could not be booted.
func_name sessions.diff.revert_turn. Body { "sessionId", "assistantMessageId" }.
Restores the workspace files to their pre-turn content: the engine verifies each file against the snapshot it captured, then writes or removes it. This writes real files. A file that changed since the turn was captured is refused rather than overwritten.
Response 200 {"ok":true,"turnDiff":{…}} — the record after the
revert; status is reverted and canReapply is now true.
Errors — 409 TURN_DIFF_CONFLICT with the engine's message when
the turn is not the latest one (Only the latest turn diff can be changed) or when a file no longer matches the captured snapshot; 404 for
an unknown session; 400 for a malformed sessionId; 403 in read-only
mode. The client shows the engine's message verbatim — it is the only
sentence that says which of the two refusals happened.
func_name sessions.diff.reapply_turn. Same body and same
coordinate rule. Puts the turn's edits back after a revert.
Response 200 {"ok":true,"turnDiff":{…}} with status:"active"
and canUndo:true. Same error set as revert.
A revert or reapply changes files the browser is already showing, so the server does two things on success and the client does the rest off one frame:
| Step | Who | What |
|---|---|---|
| Session-tree cache | server | invalidateSessionTree() — the cached tree no longer matches disk |
| Broadcast | server | session-tree-changed then workspace-files-changed SSE frames |
| Files tree | client | re-reads every directory it has open, on workspaceRevision |
| File preview | client | re-reads the open file through the refresh path (scroll preserved; a dirty draft is left alone) |
| Git panel | client | re-reads status and branches |
workspace-files-changed is a new named SSE frame with no payload. It
is the only signal the webui has that files on disk moved underneath it:
the transcript does not change, and nothing in the turn stream says so.
Returns the full settings snapshot. This endpoint is exempt from the LAN guard — it's how a remote user toggles LAN back on after locking themselves out. The same snapshot is also pushed via SSE on state changes (see ARCHITECTURE.md §5 SSE state push).
Response 200 (🆕 v1.0.1, 🔒 v2 security — PR #55 review)
{
"ok": true,
"lanBroadcast": true,
"port": 18090,
"host": "127.0.0.1",
"lanIp": "192.168.1.50",
"lanUrl": "http://192.168.1.50:18090",
"lanUrlWithToken": "http://192.168.1.50:18090/?token=…", // 🔒 v2 — FIRST-RUN BOOTSTRAP ONLY: present while tokenAcknowledged=false, omitted entirely after ack (UI falls back to lanUrl); re-issued once per rotation
"localUrl": "http://127.0.0.1:18090",
"lanBind": false, // 🔒 v2 — persisted LAN-bind opt-in; true binds 0.0.0.0 on next boot (env HOST still wins)
"bindHost": "127.0.0.1", // 🔒 v2 — what the NEXT boot resolves to (env HOST > lanBind > loopback)
"lanExposed": false, // 🔒 v2 — effective bind is not loopback
"bindRestartPending": false, // 🔒 v2 — setting no longer matches the live socket; never true when env HOST owns the bind
"lanExposureNotice": "", // 🔒 v2 — bilingual exposure disclosure (non-empty when exposed / pending)
"trustedOrigins": [], // 🔒 v2 — explicit cross-origin allowlist for CORS reflection (see below)
"mcodeCmd": "C:\\…\\mcode.cmd",
"mcodeVersion": "0.5.2",
"defaultWorkspace": "C:\\…",
"defaultModel": "minimax_api/MiniMax-M3",
"readOnly": false, // 🆕 v1.0.1 — read-only mode toggle
"tokenEnabled": true, // 🆕 v1.0.1 — token auth master switch (default true)
"currentToken": "…", // 🆕 v1.0.1 — auto-generated 32-hex token; "" after tokenAcknowledged=true
"tokenAcknowledged": false, // 🆕 v1.0.1 — operator has confirmed they saved the token
"tokenRotatedAt": 1724259600000 // 🆕 v1.0.1 — ms-since-epoch of the last rotation
}Notes on the 🔒 v2 fields:
hoststays the actual boot-time bind;bindHostrecomputes what the next boot would resolve to from current state (resolveBindHost: envHOST>lanBind>127.0.0.1).trustedOriginsis the explicit CORS allowlist — origins listed here are reflected verbatim inAccess-Control-Allow-Originin addition to the server's own serving origins (loopback + LAN address while LAN sharing is on). See SECURITY-NOTES CORS.
Fields currentToken and tokenAcknowledged are persisted to
~/.mcode-webui/settings.json (mode 0600 on Unix). currentToken
and lanUrlWithToken are omitted after tokenAcknowledged=true —
the server only ships the token while the operator still has a copy of
it in the UI.
MCODE_WEBUI_SETTINGS_PATH env overrides the file location.
Update one or more settings. v1.0.1 expanded the payload — any combination of the fields below is settable in one request. Always exempt from the LAN guard AND the read-only gate (so the admin can always toggle things remotely, even in read-only mode).
v2 request — all settable fields (🆕 v1.0.1, 🔒 v2 security — PR #55 review)
{
"lanBroadcast": true, // (existing) LAN on/off
"lanBind": true, // 🔒 v2 — persisted LAN-bind opt-in; binds 0.0.0.0 on next boot (restart-effective)
"trustedOrigins": ["https://webui.example.com"], // 🔒 v2 — explicit CORS allowlist; replaces the stored list wholesale
"readOnly": true, // 🆕 v1.0.1 — toggle read-only mode
"tokenEnabled": false, // 🆕 v1.0.1 — toggle token auth master switch
"resetToken": true, // 🆕 v1.0.1 — generate new token + broadcast auth.token_rotated SSE
"acknowledgeToken": true // 🆕 v1.0.1 — operator confirms they saved the token; server stops sending it
}trustedOrigins validation (fail-closed, whole batch): http/https
origin serialization only (scheme://host[:port] — no path / query /
userinfo), at most 16 entries of 1..200 chars each; an invalid batch is
rejected 400 before any state changes (a malformed allowlist can
never partially widen the CORS surface).
Responses
200 {"ok":true, "changed":true, …}— at least one field was updated200 {"ok":true, "tokenRotated":true, "currentToken":"…", "tokenAcknowledged":false, "tokenRotatedAt":…}— special response forresetToken:true(returns the new value so the caller can update its localStorage)200 {"ok":true, "changed":false}— no field actually changed400 {"ok":false, "error":"invalid origin: …"}(and similar) —trustedOriginsbatch failed validation500 {"ok":false, "error":"…"}— only onrotateTokendisk write failure (rare)
Multipart file upload. Saves to MCODE_WEBUI_UPLOAD_DIR and returns
the absolute path. 🔒 v2 security (PR #55 review point 3): the parser
is a bounded streaming state machine (memory O(chunk), never O(body)),
with three limits enforced mid-stream:
| Limit | Default | Env override (positive integer) | Error code |
|---|---|---|---|
| Total request body | 50 MiB | MCODE_WEBUI_UPLOAD_MAX_REQUEST |
UPLOAD_REQ_TOO_LARGE |
| Single file | 25 MiB | MCODE_WEBUI_UPLOAD_MAX_FILE |
UPLOAD_FILE_TOO_LARGE |
| Upload-directory quota | 200 MiB | MCODE_WEBUI_UPLOAD_QUOTA |
UPLOAD_QUOTA_EXCEEDED |
Request multipart/form-data with a file field.
Response 200
{
"ok": true,
"path": "C:\\…\\.mcode-webui\\uploads\\screenshot.png",
"name": "screenshot.png",
"size": 12345
}(size is the stored byte count; the file lands at its final name only
via rename() after a clean stream end — failures leave no
half-written artifact.)
Errors — status is mapped from the error code, and the code is echoed in the body so the client sees WHICH limit fired:
{ "ok": false, "error": "file exceeds size limit: 26214401 > 26214400 bytes (adjust MCODE_WEBUI_UPLOAD_MAX_FILE to allow more)", "code": "UPLOAD_FILE_TOO_LARGE" }413+Connection: close—UPLOAD_REQ_TOO_LARGE/UPLOAD_FILE_TOO_LARGE/UPLOAD_QUOTA_EXCEEDED(the over-limit body was deliberately not consumed)400—UPLOAD_MALFORMED(malformed / truncated multipart) /UPLOAD_ABORTED(client tore the stream)500— anything else (disk I/O, audit write, unknown)
Returns the model catalogue from the engine session's own config options —
not a builtin list and not anything read out of the engine binary. listModels
is per-session, so there is nothing to report until a session exists.
Response 200
{
"ok": true,
"current": "minimax_api/MiniMax-M3",
"source": "acp-session-config",
"models": [
{ "id": "minimax_api/MiniMax-M3", "name": "MiniMax-M3" }
],
"groups": [
{
"id": "__engine",
"label": "Engine session",
"models": [
{ "id": "minimax_api/MiniMax-M3", "label": "MiniMax-M3", "provider": "minimax_api", "source": "engine" }
]
}
]
}models[]entries are{id, name, label, provider, source}—idis the engine's config value,nameandlabelits display name,providerthe prefix split offid,sourceone ofengine/config/builtin.currentis the option'scurrentValue, ornullwhen the session has not reported one. It is never backfilled from a guess: a previous version wrote the default model back intocs.modelhere, which is what put an invented name into the state a later prompt would use.
If the list is empty the response adds reason: "no_session_config". The
current field is then null; nothing is written back.
When a v2 providers config is present (/api/providers PUT
target), each model carries protocol / thinkingLevels /
modalities from the config; each provider group carries
auth: {hasKey, type} (no apiKey, no baseURL — those exist
only on the /api/providers surface where the key is masked).
Context-window fields (U6). Minimax_api builtin entries whose
engine-materialised tree declares contextWindowOptions carry three
extra fields — contextWindowOptions (token counts, engine order),
contextWindowOptionHints (today only {"1000000": "higher_usage"}),
and contextLimit (the engine's current effective window, also the
highlight fallback). Models without options stay field-free. The
response top level adds currentContextWindow: the recorded
cs.model.contextWindow, falling back to the current model's
contextLimit, or null.
Change the model for the current CID. Persists into cs.model so the
composer reflects it immediately; with a live mcode session it also
calls session/set_config_option {configId:'model'}, routed through
the cid's active child. Without a session, the change is recorded
for the next one.
Request
{ "model": "minimax_api/MiniMax-M3", "contextWindow": 1000000 }model(string, optional together with the other fields — at least one field must be present) — the model id.thinking(string, optional;""clears) — the recorded effort.contextWindow(number, optional;nullclears) — U6. A safe positive integer, one of the model'scontextWindowOptions; any other value is a400 {"ok": false, "error": "invalid contextWindow"}. Recorded only, not engine-applied today: the engine's ACPset_config_optionhas no config id or wire slot for a context choice, so the route validates → recordscs.model.contextWindow→ echoes the value back. The picker highlights it via/api/models'currentContextWindow. Seedocs/webui.md"Context window" for the verified engine-side boundary.
Response 200 (engine accepted)
{ "ok": true, "model": "minimax_api/MiniMax-M3", "contextWindow": 1000000, "mcodeSynced": true }The response echoes each provided field (model, thinking,
contextWindow) alongside mcodeSynced / thinkingSynced.
Without an mcodeSessionId yet: {ok: true, model: "...", mcodeSynced: false, warning: "no mcode session yet — recorded for the next one"}.
warning distinguishes the same three cases as POST
/api/permissions — no_acp_session when the live run
is on the exec transport (structural, applies to the next turn) versus
no_client when an ACP run is expected but has no registered client.
Change the session-level permission mode. Mid-session routing goes
through session/set_config_option {configId:'permissionMode'} (see
§Protocol). The route also writes the new mode
label into the local cs.permissions so the UI updates immediately.
Request
{ "mode": "ask" }mode(string) — one ofask,auto,read,full. The webui maps these to the engine'spermissionModevalues internally; clients should send the short alias and not the engine's raw value.
Response 200 (typical — engine accepts the change)
{ "ok": true, "permissions": "Ask", "mcodeSynced": true }permissionsis the display label (Ask/Auto/Read/Full access).mcodeSynced: truewhen the engine'ssession/set_config_optioncall landed on the active child.- The change is recorded in
cs.permissionseither way, which is what selects the transport and supplies the mode for the next prompt.
Warnings — mcodeSynced: false comes with a warning saying which case
it is, because the two are not the same problem:
no mcode session yet — applies to the next one— no engine session has been created for this CID yet.no_acp_session: "this turn uses the exec transport, which has no live engine session to update — the change applies from the next turn". The engine transport is selected by permission mode (runMcodeAcpuses exec whenever the mode is not Full access) and the one-shotmcode execCLI has no persistent session to address. This is structural, not an outage.no_client— an ACP turn is expected but no live client is registered.
A missing or empty mode is not a 400. The route reads
(payload.mode || "full"), so an absent mode resolves to Full access rather
than erroring. Send the mode explicitly; do not rely on a default.
List the available permission modes. The response carries both the
webui display labels and the engine's raw permissionMode values so a
client can render the dropdown without doing the conversion itself.
Response 200
{
"ok": true,
"webui": [
{ "value": "ask", "label": "Ask", "mcodeValue": "default" },
{ "value": "auto", "label": "Auto", "mcodeValue": "auto" },
{ "value": "read", "label": "Read", "mcodeValue": "read" },
{ "value": "full", "label": "Full access", "mcodeValue": "bypassPermissions" }
],
"mcode": [
{ "value": "default", "label": "Ask" },
{ "value": "bypassPermissions","label": "Full access" },
{ "value": "auto", "label": "Auto" },
{ "value": "off", "label": "…" },
{ "value": "read", "label": "Read" },
{ "value": "full", "label": "…" }
]
}webui[] is the curated four the UI offers. mcode[] is the engine's full
PERMISSION_MODES list — six values, including off and full, which the
webui[] projection does not surface. mcodePermissionToWebui supplies the
label; off and full have no webui alias, so their label is whatever that
map yields.
Respond to an active permission / plan / ask_user prompt.
Request
{ "type": "permission", "option": "ask" }type(string) —permission|plan|planmode|askoption(string) — depends on type:permission:ask|auto|fullplan:agree|skip|addplanmode:continue|denyask:esc(skip) |<index>(option) |<text>(free-form)
Response 200
{ "ok": true, "deprecated": true, "note": "use /api/send for new flow" }The route is a legacy no-op: it logs the call and answers without acting on
it. Answers go through POST /api/send with {content, isAskAnswer: true}.
deprecated: true is always present — a client that only checks ok will keep
calling an endpoint that does nothing.
Return the merged v2 provider catalogue, with every apiKey masked
(apiKeyMasked) — the plaintext credential is never returned in any
response path. The response also names the file paths the server
actually read for each layer, so an operator can confirm which file
the live config came from.
Layered resolution: MCODE_WEBUI_MODELS_CONFIG env → cwd models.json
→ the engine's <engine data dir>/config.yaml under custom_provider
(the PUT write target). Same-id provider deep merge; models dedupe by id
with the higher layer winning.
The env and cwd layers are deployment-owned and are never written by
any handler. The third layer used to be a webui file of its own
(~/.mcode-webui/providers.json); it is now the engine's own provider
store, and that file is deprecated — see "Provider storage" below.
Response 200
{
"ok": true,
"version": 2,
"providers": [
{
"id": "openai_compat",
"label": "OpenAI Compat",
"enabled": true,
"protocol": "openai",
"auth": {
"type": "byok",
"hasKey": true,
"apiKeyMasked": "sk-a***yz",
"baseURL": "https://api.openai.com"
},
"models": [
{
"id": "gpt-4o-mini",
"label": "GPT-4o mini",
"contextLimit": 128000,
"thinkingLevels": ["low", "medium", "high"],
"modalities": ["text", "image"]
}
]
}
],
"sources": {
"env": null,
"cwd": "/srv/webui/models.json",
"user": "/home/you/.mcode-webui/providers.json"
},
"userPath": "/home/you/.mcode-webui/providers.json"
}-
sources.useranduserPathstill name the deprecated~/.mcode-webui/providers.json. The fields did not change and the values did not either: both are documented as "the files this server resolved", and an operator diagnosing a missing provider still needs to be told what to look at. What changed is the answer — the file is read only until the migration completes, and is never written again. The live catalogue is the engine store;GET /api/modelsreads it there too. -
auth.apiKeyMaskedis the only apiKey shape returned by any route in this surface. A test (andscripts/check-docs-alignment.mjs) pins the rule: the plaintext key MUST NEVER appear in any/api/providers*response, regardless of which layer held it. Path forms are reported as the server resolved them, and nothing is re-resolved.sources.cwdis<process.cwd()>/models.json, andprocess.cwd()is the kernel-reported working directory — on macOS that is the fully-resolved form, so a server started under/varreports/private/var/.... That is the correct answer to "which file did you read", and the write side uses the same resolver, so the file the response names is the file thePUTwill land in.sources.useranduserPathcome straight fromMCODE_WEBUI_DATA_DIRand are reported exactly as configured. -
sources.envisnullwhenMCODE_WEBUI_MODELS_CONFIGis unset;sources.cwdis omitted from the layer set in that case (the env override is the cwd file).
Validate-and-persist a v2 provider config to the engine's provider
store — <engine data dir>/config.yaml under custom_provider,
written with mode 0600. The env / cwd layers are deployment-owned and
never written here, and neither is the deprecated
~/.mcode-webui/providers.json.
The handler performs one write: a temporary file plus a single
rename of the whole document. There is no second file to fall out of
step, so a request either lands completely or changes nothing — a
concurrent reader always sees a whole catalogue, never a mixture, and
never a partially written YAML document. The layer set is re-read on
the next call, and the handler broadcasts an SSE providers.updated
named event with the masked payload so every connected client refreshes
its catalogue without polling. /api/models picks up the change on
the next request — no restart required.
Capability gate. The two write endpoints (PUT /api/providers
and POST /api/providers/preset/:id/enable) declare
authCredentials and gate hard on their sub-item
(updateUserModelProvider / createUserModelProvider). A provider
that declares the sub-item absent answers
501 {ok:false, code:"engine_capability_not_supported", …} rather than
acknowledging a configuration the engine will never read. On the
default acp transport no provider is registered yet, so the gate
reports unregistered-transport and the write proceeds. The three read
endpoints declare the same capability and gate soft — they report
degradation and keep serving.
Legacy migration. While the engine store carries no migration
marker, the deprecated providers.json is still the authority: webui
folds it into the store, losslessly, on the next read, and stamps the
marker on success — after which the file is never read again. A failed
migration (an unparseable config.yaml, a write that could not
complete) leaves the store untouched and the old format readable, and
the next read retries. Field-by-field equivalence is pinned by
packages/webui/test/lib/engine/provider-migration.test.js.
Request
{
"version": 2,
"providers": [
{
"id": "openai_compat",
"label": "OpenAI Compat",
"enabled": true,
"protocol": "openai",
"auth": { "type": "byok", "apiKey": "sk-realkey...", "baseURL": "https://api.openai.com" },
"models": [
{ "id": "gpt-4o-mini", "label": "GPT-4o mini", "contextLimit": 128000 }
]
}
]
}Response 200
{
"ok": true,
"providers": [ /* masked view, same shape as GET */ ],
"path": "/home/you/.minimax/config.yaml",
"engineSync": { "ok": true, "written": true, "keys": ["openai_compat"] }
}pathis the file this handler wrote: the engine'sconfig.yaml. It used to be~/.mcode-webui/providers.json.engineSyncreports the store write itself.written: falsemeans the document would have come out unchanged (a no-op PUT does not re-chmod a file an operator just hand-edited). It isok: truewhenever the store accepted the write.400 BAD_BODY— invalid provider shape, unknown protocol, or validation failure (each error carries a human-readableerrorstring with the offending field).500 WRITE_FAILED— the store refused or could not perform the write. Two causes, and the second is the one that matters: aconfig.yamlthat does not parse is refused, never overwritten, because rewriting it would destroy every engine setting the store does not own. In both cases the previous document is intact, the nextGETreturns the catalogue the client already had, and the operator can retry.
Run a per-protocol minimal connectivity probe. Local key-format
validation happens BEFORE any network call — a malformed key gets
400 INVALID_KEY with no fetch. A successful probe returns
{ok:true, latencyMs, detail}; a network failure returns
502 PROBE_FAILED with the upstream status code (no response body
— upstream error messages can echo the credential in a misconfigured
proxy).
Request
{
"protocol": "openai",
"auth": { "type": "byok", "apiKey": "sk-realkey...", "baseURL": "https://api.openai.com" }
}Response 200 (probe succeeded)
{ "ok": true, "protocol": "openai", "code": "OK", "latencyMs": 187, "detail": "HTTP 200" }Response 400 (malformed key — no network call)
{ "ok": false, "protocol": "openai", "code": "INVALID_KEY", "error": "auth.apiKey is too short (< 8 chars)" }Response 502 (upstream rejected the request)
{ "ok": false, "protocol": "openai", "code": "PROBE_FAILED", "error": "HTTP 401", "latencyMs": 412 }- Protocol whitelist:
openai(GET /v1/models),anthropic(POST /v1/messageswithclaude-3-5-sonnet-20241022,max_tokens:1),gemini(GET /v1beta/models?key=...). Anything else returns400 BAD_PROTOCOLwith no network call. - The key is sent only to the
baseURLfrom the request body (or the protocol default). The plaintext key never leaves the server in any response path.
Built-in preset provider gallery (ticket 02). The response lists every
curated template (currently 11 — 智谱 / Kimi / 百炼 / 火山 / mimo /
minimax / opencode go / OpenRouter / Claude Code / Codex / DeepSeek)
with the metadata each one would write into the user-level file on
enable. The enabled flag and enabledIds array mark templates whose
id already appears in the configured catalogue, so the UI can render
"Enabled" / "Enable" buttons without a second round-trip.
Templates never carry key material: apiKey / apiKeyMasked / hasKey
are intentionally absent from the gallery payload. Users supply the
credential after enabling a preset.
A preset's auth.type (byok or coding-plan) is currently
COSMETIC at this layer: no code path branches on it, and an enabled
preset with empty key is consumed identically to a byok record by
the engine. The label is preserved on the persisted record so a
future subscription-auth behaviour (per-provider key flow,
auto-refresh, scoped quotas) has a stable placeholder to attach to;
it does NOT change behaviour today.
Response 200
{
"ok": true,
"version": 2,
"presets": [
{
"id": "zhipu",
"label": "智谱 (Zhipu / GLM)",
"protocol": "openai",
"auth": { "type": "byok", "baseURL": "https://open.bigmodel.cn/api/paas/v4/" },
"models": [
{ "id": "glm-4-plus", "label": "GLM-4 Plus", "contextLimit": 128000, "modalities": ["text"] }
],
"enabled": false
}
],
"enabledIds": ["zhipu"]
}One-click materialisation of a preset into the user-level catalogue.
The handler resolves the template, merges it into the existing
catalogue, writes the file via the same writeProvidersConfig
pipeline that PUT uses (atomic rename, full v2 validation gate), and
broadcasts the standard providers.updated SSE event so every
connected client refreshes its catalogue. The next /api/models
read picks up the new entries without a restart (the user-level file
is re-read on every call).
Idempotent: a second call for the same id returns 200 with
alreadyEnabled: true and the existing masked record rather than
clobbering the user's later edits to apiKey / baseURL. Custom
providers that share an id with a preset are NOT overwritten — the
handler surfaces the existing record under the same idempotent
contract.
The persisted record starts with an empty apiKey; the user fills
it through the same form the custom-providers UI uses.
Response 200 (newly enabled)
{
"ok": true,
"alreadyEnabled": false,
"provider": { /* masked view, same shape as GET */ },
"path": "/home/you/.mcode-webui/providers.json"
}Response 200 (idempotent — preset already configured)
{
"ok": true,
"alreadyEnabled": true,
"provider": { /* the existing masked record */ }
}400 UNKNOWN_PRESET—:iddoes not name a known template.500 WRITE_FAILED— disk I/O failure (the in-memory state did not change; the operator should retry).
Four endpoints behind the 「用量与模型」 tab's source switcher, the
「使用中」 badge and the MiniMax API key row. Each one is a thin window
over an engine method that already existed
(packages/local-runtime-v2/src/local/cli-service.ts:
getMiniMaxModelSource, setMiniMaxModelSource,
upsertMiniMaxApiKey, testUserModel) and previously had no route.
These are NOT the provider catalogue. /api/providers* is webui's
own store of custom BYOK endpoints. This family is the ENGINE's MiniMax
credential state — minimaxModelSource and minimax_api.apiKey in the
engine's own config.yaml. The two sit on adjacent tabs, share a masking
convention, and share no storage and no validation.
Gate. The four methods hang off the v2 cli-service's own
modelProviders requirement, which is not one of the 14 declared
capability keys, so this family is gated on the LIVE member rather than
on a declaration: 503 engine_host_unavailable when no runtime is
booted, 501 engine_member_unavailable when the booted host does not
carry the method. See server/engine/model-source.js.
Masking. apiKey is masked in every response. The mask is the
engine's own (service/model-system/secret.js), forwarded unchanged, and
no path in the route can unmask it.
The read the settings tab opens on: the active source, plus the stored key's masked projection.
Response 200
{
"ok": true,
"source": "token_plan",
"apiKey": {
"available": true,
"hasKey": false,
"masked": null,
"testState": null,
"lastTestedAtMs": null
}
}source is token_plan (the managed Token Plan credential) or
minimax_api_key (the user's own BYOK key). apiKey.available: false
is NOT hasKey: false: the first means the host could not report the
key half at all, the second means it reported that nothing is stored. A
UI that collapsed the two would tell a user with a saved key that they
have none.
501 engine_member_unavailable— the host has nogetMiniMaxModelSource.503 engine_host_unavailable— no runtime booted.502 UNKNOWN_MODEL_SOURCE— the engine reported a value outside the two its own type allows. Refused rather than rendered, because the UI has no way back out of a source it cannot name.
Switch the active source. The response reports what the engine PERSISTED, not what was requested, so the badge can never disagree with the config.
Request { "source": "token_plan" | "minimax_api_key" }
Response 200 { "ok": true, "source": "minimax_api_key" }
400 INVALID_MODEL_SOURCE— missing or unknownsource. Checked before the engine is reached, so a typo costs no runtime round trip.400 BAD_FIELD_TYPE—sourcewas not a string.400 NO_API_KEY— the engine refused the BYOK direction because no key is stored. This code is the UI's cue to point the user at the key field rather than to show a failure.
Upsert the BYOK key, optionally switching to it in the same call.
Request { "apiKey": "<raw key>", "saveAndUse": true }
The keep-key sentinel. An absent, empty or whitespace-only apiKey
keeps the stored key, calls no engine write, and answers 200 with
changed: false plus the current masked status. It exists because the
GET can only return a MASK and the engine rejects a mask submitted as a
key (INVALID_API_KEY); a UI that round-tripped its own masked state
would turn every save into a failure. Same convention and same
empty-string spelling as PUT /api/providers.
saveAndUse is the engine's own flag: it writes the key AND switches
the source to minimax_api_key in one transaction, so the tab never
shows a saved key beside a source that was not switched.
Response 200
{
"ok": true,
"source": "minimax_api_key",
"apiKey": { "available": true, "hasKey": true, "masked": "sk-a*******6789" },
"changed": true,
"saveAndUse": true
}400 BAD_FIELD_TYPE—apiKeywas not a string, orsaveAndUsewas not a boolean. A non-string key is refused rather than treated as the keep sentinel, which would turn a client's bug into a successful no-op.400 INVALID_API_KEY— the engine's own refusal (empty or masked).500 engine_error— a throw with no engine status. Its message is REPLACED, not forwarded: an exception string from an unrecognised thrower is the one place a credential could still be echoed.
Connectivity probe. Request { "modelId"?: "MiniMax-M3" }; the
engine falls back to the first configured MiniMax model when it is
absent, and an unknown id is the engine's own 404.
The probe always runs against the STORED key on the minimax_api
provider, and says so in tested: "stored_key". Two limits are the
engine's contract, not this route's: v2's testUserModel takes no key
override, so an unsaved key cannot be probed; and the managed Token Plan
credential is not a model-service key, so the Token Plan source has
nothing here to probe with.
Response 200 — for a completed probe, successful or not:
{
"ok": true,
"success": true,
"providerId": "minimax_api",
"modelId": null,
"tested": "stored_key",
"status": {
"state": "available",
"lastTestedAt": 1700000000000,
"lastErrorCode": null,
"lastErrorMessage": null
}
}success: false is a COMPLETED probe of a model that did not answer, so
it stays 200 — the same split POST /api/providers/test makes. Only a
refusal to try is a non-200: 503 / 501 from the gate, or the
engine's 400 NO_API_KEY when nothing is stored to test.
Read the Token Plan quota for the account. Both routes dispatch through
usageRoute.handleUsage.
The figures come from the engine. mcode holds the account credential and reports
the plan tier plus each window's remaining percentage through the ACP extension
method mcode/account/status; webui keeps no Subscription Key of its own (see
server/lib/usage.js).
remaining and weeklyRemaining are percentages, and both appear only when the
engine reported a figure — a body without them means "no gauge to draw", not 0%.
resetAt and weeklyResetAt are unix seconds. ok: false with error means the
engine could not be asked (no ACP client, or the method failed); the HTTP status
stays 200 because the request itself succeeded.
(An older revision of this entry advertised GET /api/usage. That route was
never wired up, and the heading now names the two POSTs the router actually
registers, so scripts/check-docs-alignment.mjs keeps agreeing with it.)
Response 200
{
"ok": true,
"source": "acp",
"remaining": 99,
"weeklyRemaining": 86,
"resetAt": 1790164800,
"weeklyResetAt": 1790524800,
"fetchedAt": 1790000000000
}Fetch per-turn context usage from the mavis runtime db. This is the
source of truth for "已用 N / 占比 N%" in the right panel.
Response 200
{
"ok": true,
"found": true,
"sid": "mvs_…",
"rows": [{ "ts": 1730000000000, "input": 1000, "output": 800 }],
"totalInput": 1000,
"totalOutput": 800,
"totalCacheRead": 500,
"totalCacheWrite": 200,
"totalReasoning": 120,
"contextUsed": 1920,
"model": "MiniMax-M3",
"modelLimit": 524288,
"firstTs": 1730000000000,
"lastTs": 1730000000000,
"dbPath": "/home/you/.mavis/usage.db"
}contextUsedistotalInput + totalOutput + totalReasoning— the cache counters are a subset of input, not additional context.modelLimitcomes from the model's config, and isnullwhen unknown.found: false(withdbExists) when the db or the session row is absent — see the 404 shape below.
Push the caller's current state to its SSE clients. The usage popover's refresh
button calls this and then POST /api/usage, which is what actually re-reads the
quota from the engine.
Response 200 {ok: true}
Predict quota exhaustion time. Reads $WEBUI_DATA_DIR/usage-history.ndjson
and extrapolates the 5-hour and weekly windows. Best-effort: a missing or
empty history file still answers 200, so the UI can render a "collecting
data…" placeholder rather than an error.
Response 200
{
"ok": true,
"forecast": {
"hoursUntilExhaustion5h": 3.5,
"hoursUntilExhaustionWeekly": 82.0,
"confidence5h": 0.8,
"confidenceWeekly": 0.6,
"samples": 12,
"model": "least-squares-linear"
}
}Response 200 (not enough data) — the numeric fields are still present and
null, they do not collapse away:
{
"ok": true,
"forecast": {
"hoursUntilExhaustion5h": null,
"hoursUntilExhaustionWeekly": null,
"confidence5h": 0,
"confidenceWeekly": 0,
"samples": 0,
"model": "least-squares-linear",
"reason": "no_history"
}
}reason is "no_history" (empty/missing file) or "insufficient_samples"
(fewer than 3 samples). An hoursUntilExhaustion* value is always a future
number — the model clamps a past exhaustion to "won't run out" rather than
reporting a negative time.
These endpoints wrap the acp protocol methods the webui can call.
Each route dispatches through server/lib/mcode-rpc.js and pins the
notification on the active child's subprocess (the cid's per-prompt
McodeAcpClient, not the singleton). Engine refusal modes
(unsupported, no_client, not_found, policy) are mapped to
501 / 503 / 404 / 409 respectively; everything else is 502 or 500.
Calls session/set_mode {sessionId, modeId}. The webui uses this for
plan / goal mode switches; permission-mode switches go through
session/set_config_option instead (see
§POST /api/permissions).
Request {sessionId: "mvs_…", mode: "plan_mode" | "goal_mode" | "default" | …}
Response 200 {ok: true, mode: "plan_mode", data: <acp reply>}
Calls session/set_config_option {sessionId, configId, value}. The
generic config-option route: permissionMode and model are the two
real callers today.
Request {sessionId: "mvs_…", key: "permissionMode", value: "default"}
Response 200 {ok: true, key: "permissionMode", value: "default", data: <acp reply>}
When key === "permissionMode" the route also writes the webui label
into the local cs.permissions so the UI updates without waiting for
the next SSE state push.
Calls session/cancel {sessionId}. This route only sends the
notification; on failure it answers 200 { ok: true, cancelled: false, warning, code, killEndpoint: "/api/stop" }. The gentle-then-SIGKILL
cascade lives behind POST /api/stop — call it explicitly when a hard
kill is what you want.
Request {sessionId: "mvs_…"}
Response 200 (notification accepted) {ok: true, cancelled: true, data: <acp reply>}
Calls session/load. Loads an mcode session into the webui without
switching the active webui session. Pass createWebuiEntry: true to
also append a webui sidebar entry for it.
Request
{
"sessionId": "mvs_…",
"cwd": "C:\\…",
"createWebuiEntry": false
}Response 200
{ "ok": true, "sessionId": "mvs_…", "webuiEntry": null }When createWebuiEntry: true and no webui session referenced the
mcodeSessionId yet, webuiEntry is the newly-created sidebar entry
(id, mcodeSessionId, title "Mcode session", createdAt, updatedAt).
Calls session/activate. Switches the current CID to the named mcode
session; resets the local context so the next prompt starts on the new
session.
Request {sessionId: "mvs_…"}
Response 200
{ "ok": true, "activeSessionId": "mvs_…", "data": <acp reply> }Calls session/list. Lists every mcode session; if cwd is supplied,
the response is filtered to that workspace (path-normalised: case-
insensitive, trailing slash-insensitive, \ and / interchangeable).
Response 200
{ "ok": true, "sessions": [<mcode session rows>], "cwd": "C:\\…" }Returns the engine's agentInfo (from the initialize reply) and the
engine-capabilities view: the declared 14-key capability surface of the
active engine provider, the same declaration GET /api/engine-capabilities
serves. Used by the webui to decide which UI controls to enable.
This field's contract changed in M3 batch B4. capabilities used to
carry MCODE_ACP_CAPABILITIES, a hand-maintained flat {method: boolean}
table of the ACP JSON-RPC surface (set_mode, set_config_option,
cancel, activate, fork, resume, delete, load, close, list,
new, prompt). Those twelve keys are gone: a consumer reading
capabilities.set_mode now gets undefined and must fail loudly. What
replaced them answers a different question — "does the engine have this
capability at all" — with the 14 matrix keys, each
{level, missing?, reason?}. The ACP wire table is still exported from
server/lib/mcode-rpc.js and is still a true statement about the
engine's ACP surface; it simply no longer travels on this endpoint.
The declaration appears exactly once, under capabilities, and three
sibling keys say where it came from and what to do about its gaps.
Response 200
{
"ok": true,
"mcodeVersion": "0.5.2",
"mcodeName": "mcode",
"mcodeTitle": "mcode",
"capabilities": {
"sessionCrud": { "level": "full" },
"streamingSend": { "level": "full" },
"interrupt": { "level": "full" },
"toolSkillInvocation": { "level": "full" },
"turnDiff": { "level": "full" },
"turnRewindRedo": { "level": "full" },
"plugins": { "level": "full" },
"mcp": { "level": "full" },
"subagents": {
"level": "partial",
"missing": ["getDelegationSnapshot", "stopDelegation"],
"reason": "delegation snapshot/stop live on the TuiRuntimeAdapter access-context, not on the v2 CliService surface (design §1.3 v2)"
},
"usageStats": { "level": "full" },
"authCredentials": { "level": "full" },
"updateCheck": {
"level": "none",
"reason": "interface-absent: no update-check method anywhere in local-runtime-v2 (design §1.3 v2)"
},
"fileReadWrite": {
"level": "partial",
"missing": ["file-write"],
"reason": "workspace read browsing only; no write API — writes go through in-turn tools (design §1.3 v2)"
},
"gitOperations": {
"level": "partial",
"missing": ["git-diff", "git-commit", "git-branch"],
"reason": "read-only metadata + review link; change mutation is outside this package (same discipline as v1's read-only Git facade)"
}
},
"capabilitiesProvider": "local-runtime-v2",
"capabilitiesProviderFor": "transport",
"capabilitiesUnavailable": {
"none": ["updateCheck"],
"partial": [
{ "key": "subagents", "missing": ["getDelegationSnapshot", "stopDelegation"] },
{ "key": "fileReadWrite", "missing": ["file-write"] },
{ "key": "gitOperations", "missing": ["git-diff", "git-commit", "git-branch"] }
]
},
"notes": {
"set_mode": "Takes a modeId from the session's availableModes.",
"set_config_option": "With configId 'permissionMode' this changes the mode mid-session.",
"cancel": "Sent as a notification; /api/stop falls back to SIGKILL only when the client cannot be reached.",
"activate": "One acp client tracks a single active session.",
"fork": "Implemented by the engine; no webui route exposes it yet."
}
}mcodeVersion is "unknown" before a client has attached (no initialize
reply yet); the endpoint does not invent a version.
capabilitiesProvider is the provider whose declaration answered, and
capabilitiesProviderFor says HOW it was chosen. A consumer should
branch on the second one:
"transport"— the activeMCODE_WEBUI_TRANSPORT's own registered provider answered."default"— no provider claims that transport yet (arrives with M4), so the default provider's declaration is standing in. The view is still a real, reviewed declaration, but it is not necessarily the connected engine's, and reporting it as such would be a lie.
capabilitiesUnavailable is the degradation summary the capability-driven
UI renders from: a none key means hide the entry point, a partial key
means hide or disable exactly the listed sub-actions. It is the one field
that is not the declaration itself, and a consumer should not have to
re-derive it from a taxonomy with three levels and two optional fields.
The server-side authorize gate (server/lib/authorize.js) asks the
user to approve destructive actions (session.delete,
sessions.cleanup-orphans, session.cleanup-all, session.export,
session.search, token.reset, slash.clear, startup.cleanup).
The pending requests are exposed through this single endpoint — the
client UI shows the modal, the user clicks Allow / Deny, and the
decision is delivered back here. See CAPABILITIES.md §13
for the action whitelist and the default 5-minute timeout.
Request
{ "requestId": "auth-…", "approve": true }Response 200 (resolved) {ok: true, approved: true, decidedBy: "user", decidedAt: 1730000000000}
Errors
- 400 missing/empty
requestId - 404
{ok: false, error: "no pending request with that id"}(already decided, expired, or never existed) - 410
{ok: false, error: "already decided"}is not returned — the route treats "already decided" and "no such request" identically as 404, by design: replaying a decision must not leak whether the request originally existed.
Overwrite slices of a CID's in-memory state, for exercising the UI without a real engine. Each field is optional; supplied ones replace, omitted ones are left alone. This is not a raw SSE event injector — it mutates state, and the normal push path then broadcasts it.
Request (the CID comes from the ?cid= query, not the body)
{
"goal": { "text": "…", "done": false },
"todo": [{ "id": "1", "text": "…", "done": false }],
"ask": { "questions": [{ "header": "…", "question": "…", "options": ["…"] }] },
"plan": { "title": "…", "summary": "…", "options": ["…"] },
"enterPlanMode": { "active": true },
"appendChat": ["› a line to append", "● and a reply"]
}appendChat must be an array of chat lines. A bare string is silently
ignored — the response still says ok: true with an empty applied, so
check applied.appendedChatLines. The other fields are merged or replaced
according to their own shape (todo is replaced wholesale, goal /
ask / plan / enterPlanMode are shallow-merged into the existing
object).
Response 200 {"ok": true, "applied": {…}, "cid": "…"} — applied names
each field that was actually consumed, which is how you tell a no-op from a
write.
Gating: this endpoint only works if DEBUG_INJECT=1 is set in the
server's environment. The server logs a warning every time it's
called. Production deployments should leave the env unset.
Returns the full per-cid state including internal flags. Same
DEBUG_INJECT gating.
Returns webapp/out/index.html (the Next static export's entry point).
Served by serveIndex. There is no public/ fallback for the main UI
— the legacy /app/*.js, /styles/*.css, /lib/marked.min.js, and
/brand-logo.png paths are unreachable (they were removed along with
the vanilla-JS SPA; see test/lib/static.test.js).
Returns the file from webapp/out/ only. Served by serveStatic. The
Trajectory Studio under public/trajectory/ is mounted at /trajectory/
by its own handler (separate backend, CSP, token posture) — it is not
part of this static root. Cache headers:
- HTML:
no-cache(revalidate every time) _next/static/*(content-hashed):public, max-age=31536000, immutable- everything else:
public, max-age=3600
There is no manual ?v=N cache-bust any more — every chunk URL under
_next/static/<hash>/… is content-addressed and self-cancelling on rebuild.
Read-only, declaration-backed: which of the 14 engine capability keys a
provider supports, plus the unavailable summary the capability-driven
UI renders from. Boots no host and runs no probe. ?provider= defaults
to local-runtime-v2 — unchanged since B1, so every existing caller keeps the
declaration it had. The other three registered providers are
tui-runtime-adapter (the in-process adapter surface), acp (the
mcode acp subprocess protocol) and exec (the one-shot mcode exec
subprocess).
Registering a provider is not routing it. Both acp and exec are
registered and unreachable: no capability gate resolves to either of
them, so a server running on either transport still evaluates every
gate against no provider at all. ?provider=exec is answerable today;
a gate that consults the exec declaration is M4-3's work.
Response 200
{
"ok": true,
"provider": "local-runtime-v2",
"transport": "runtime",
"capabilities": {
"sessionCrud": { "level": "full" },
"plugins": { "level": "partial", "missing": ["…"], "reason": "…" },
"updateCheck": { "level": "none", "reason": "interface-absent: …" }
},
"unavailable": { "none": ["updateCheck"], "partial": [{ "key": "plugins", "missing": ["…"] }] }
}(capabilities carries all 14 keys; three are shown.)
A none entry may carry an extra servedBy: "<providerId>" alongside its
reason. It does not change the level or the unavailable roll-up — the
provider really has none of that capability. It records that webui still
serves the endpoint, from another provider's in-process host. Both
transport providers use it for exactly two keys (turnDiff, plugins):
neither the acp protocol nor the exec CLI has a diff method or a plugin
method, yet those thirteen endpoints work on either transport because
they project the in-process local-runtime-v2 host and gate on no
transport at all. That is why the field is a per-key declaration field
rather than an acp special case: the exception belongs to the two
routes, so every transport inherits it. A client that wants to know who
answers a request should treat servedBy as "not a degradation" — and
must not read it as the capability being present.
The exec declaration is the one worth reading before writing a client
against it, because its shape follows from the transport having no
request channel: mcode exec takes a prompt on stdin and writes a
stream-json event stream to stdout, so there are no methods to call
and nothing to declare full except sending. It is full on
streamingSend, partial on sessionCrud (it can re-enter or resume an
existing session but cannot list, load, close or delete one), partial
on toolSkillInvocation, mcp and usageStats, and none on the other
six — including interrupt and authCredentials, which the acp provider
answers partial. Each reason names the file and line it was taken
from, and the level is a statement about the transport's interface, not
about which endpoints currently respond under it.
Errors — 404 {"ok":false,"code":"unknown_engine_provider","knownProviders":[…]} for an unknown ?provider= (caller confusion — never 501). Any future route gated on an undeclared capability answers 501 {"ok":false,"code":"engine_capability_not_supported","capability","provider","missing"?,"reason"?} — expected degradation, not a server fault; treat it as "hide the entry point", not as an error toast.
Contract details (the 14-key table, every provider's levels, the
migration state) live in docs/webui.md
under "Engine capability declaration".
The 工作树 settings page's list: every worktree Git has registered for one
repository. workspace is optional; without it the request's own conversation
workspace is used, and with neither source present the endpoint answers 400
no_workspace rather than guessing the server's cwd.
Response 200 — the engine's WorkspaceGitWorktreeList, field for field:
{
"ok": true,
"workspace": "/home/u/repo",
"current": "/home/u/repo",
"worktrees": [
{ "path": "/home/u/repo", "branch": "main", "head": "abc1234",
"isMain": true, "isLocked": false, "isActive": true,
"isMcodeManaged": false, "lastModifiedMs": 1700000000000 }
]
}lastModifiedMs is OPTIONAL and ok: false is a report, not a transport
failure. A folder that is not a Git repository answers 200 with
ok: false and the engine's own code — not_git_repository,
workspace_unavailable or worktree_list_failed — because the request
succeeded and the engine reported a fact about the user's directory. A silent
empty list would tell the user they have nothing to clean up.
400 no_workspace— neither?workspace=nor a conversation workspace.403 workspace_outside_allowed_roots— the named path is outside the allowed roots (assertWorkspacePath, the same boundary as/api/fs/*). Checked before the engine is reached.501 engine_services_unavailable/worktree_service_unavailable503 engine_host_unavailable
There is deliberately no create endpoint: the desktop page
(design-ref/screenshots/ref-23.jpg) has no create button and the port
declares no create method.
The page's 「一键移除」 over a selection. The engine decides each item's fate and its verdicts pass through per item.
Request — items must be a non-empty array of {workspace, worktreeDir};
activeWorktreeDir is optional and means "let the engine decide" (it defaults
to the item's own workspace inside the service).
{ "items": [{ "workspace": "/home/u/repo", "worktreeDir": "/home/u/repo/.worktrees/a" }] }Response 200
{
"ok": true,
"removedPaths": ["/home/u/repo/.worktrees/b"],
"failedItems": [
{ "worktreeDir": "/home/u/repo", "reason": "main_worktree",
"error": "The main worktree cannot be removed" }
]
}ok: true means the REQUEST was carried out, not that something was deleted:
a selection where every item was refused still answers ok: true with a full
failedItems list. reason is narrowed to the engine's closed
WorktreeRemovalReason set (main_worktree / active_worktree /
not_found / locked_worktree / dirty_worktree / unknown); a value
outside it is reported as unknown rather than shipped as raw text.
worktreeDir is NOT containment-gated separately from workspace, and that is
deliberate: the engine only removes a path that git worktree list reports as
a linked worktree of that repository, which is strictly stronger than a root
check. A forged path achieves a not_found refusal and nothing else.
400 invalid_removal_request— the body is not a removal request.403 workspace_outside_allowed_roots— one item names an out-of-root repository. The WHOLE batch is refused: an out-of-root repository is a forged request, not a worktree that happened to fail.501/503— the same presence gate as the list.
All errors follow one of these shapes:
{ "ok": false, "error": "human-readable message" }{ "ok": false, "code": "unsupported", "error": "session/set_mode not implemented by this engine" }{ "ok": false, "error": "LAN 访问已关闭。在本机打开设置开启。" }The HTTP status is appropriate to the cause (400 / 401 / 403 / 404 / 409 / 413 / 500 / 501).