mypi serve is an optional REST/JSON server over the same application commands as
the CLI and the stdio MCP server. It runs in the foreground; the engine never starts
it on its own. Every route lives under /v1.
mypi token create phone --permission read
mypi serve [--listen <IP>] [--port <n>]- Without flags it listens on
127.0.0.1:8737.--port 0picks a free port. - When it is ready it prints one line to stdout:
{"status":"listening","url":"http://127.0.0.1:8737"}(the real port after--port 0). - SIGINT or SIGTERM (Ctrl+C) stops it with exit code 0: change streams are closed and the port is released.
mypi servevalidates its arguments and runs the server as a child process,node dist/src/http/main.js, so other CLI commands never load the HTTP server or the MCP SDK. SIGINT and SIGTERM are forwarded to it, and a second delivery (Ctrl+C reaches the whole process group) is harmless. The CLI exits with the child's exit code; a child killed by a signal gives 128 + the signal number.- It refuses to start without any API token, without an initialized database, or with a database schema other than the one the engine expects (12).
- The server binds only to loopback unless
--listen <IP>names another address, for example the machine's Tailscale IP.--listentakes one IP address, not a host name.0.0.0.0is accepted but exposes the API on every interface; avoid it. - There is no TLS in the process. Encryption comes from the network it is exposed on (Tailscale). Never publish the port to the internet.
- No CORS headers are sent; browsers on other origins cannot call it.
mypi token create <name> --permission read|write [--sender global|CODE]
mypi token list
mypi token revoke <name>- Tokens live in
$XDG_CONFIG_HOME/mypi/api-tokens.json(~/.config/mypi/…withoutXDG_CONFIG_HOME), a private file (0600, directory 0700) that keeps only SHA-256 hashes. A symlink or a file readable by others is refused. createprints the token value (mypi_…) once; it cannot be shown again.listshows names, permissions, senders and creation times, never hashes.- Every request sends
Authorization: Bearer <token>; a browser sends themypi_sessioncookie instead (see Web UI). The file is read on every request, sorevoketakes effect at once, without a restart; open/v1/changesstreams of a revoked token, over the header or the cookie, end within one poll tick (about 1 s). - The
mypi_sessioncookie is accepted only when there is noAuthorizationheader (the header wins) and only for reads: a write route answers 403forbidden, even for a write token. An unknown or revoked cookie answers 401 and clears the cookie. readtokens only read;writetokens also write. A write token has asender(defaultglobal): the session key on whose behalfPOST /v1/messagesand status changes run. The parent rule applies as everywhere:globalmay send only to projects and organizations, a project only to its own tasks. A status change queues a milestone for the parent that last sent the task a message, else the project chat, unless the sender is that recipient: a project's sender queues none for its own task unless the organization wrote to the task last, in which case the milestone goes to the organization.
Every JSON response is { "apiVersion": 1, "data": … } or
{ "apiVersion": 1, "error": { "code": …, "message": … } }.
| Status | Code | When |
|---|---|---|
| 400 | invalid_request |
Unknown or repeated query parameter, bad value, unknown or missing body field, unknown task or project, any refused input |
| 400 | invalid_request |
A request target that cannot be parsed as a URL (logged with path -) |
| 400 | invalid_json |
The body is not JSON |
| 401 | unauthorized |
Missing or unknown bearer token or session cookie (checked before routing) |
| 403 | forbidden |
A write route with a read token or a browser session cookie |
| 404 | not_found |
Unknown path |
| 405 | method_not_allowed |
Known path, other method |
| 413 | payload_too_large |
Body over 1 MiB |
| 415 | unsupported_media_type |
A write without Content-Type: application/json |
| 500 | partial |
A multi-part update saved some parts; error.saved and error.missing list them |
| 500 | internal |
An unreadable or invalid token file, with the fixed message API token file is unreadable; anything else with the engine's message (for example a database that needs a restart after a migration) |
An unknown task is a 400, not a 404: 404 means only an unknown route.
A task key is CODE-N (MP-12), a project code is CODE (MP).
| Method and path | Permission | Command | data |
|---|---|---|---|
GET /v1/info |
read | — | { version, schemaVersion, changeId, instance } |
GET /v1/session |
read | — | { name, permission, via }, via is bearer or cookie |
POST /v1/session |
none | — | body { token } → { name, permission } and the mypi_session cookie; 401 Unknown token |
DELETE /v1/session |
none | — | Content-Type: application/json required → { loggedOut: true }, the cookie is cleared |
GET /v1/changes |
read | — | SSE stream, see below |
GET /v1/projects?org |
read | project_list |
{ items: project[] } |
GET /v1/projects/{CODE}/flow |
read | project_flow |
flow |
GET /v1/tasks?project&status&q&cursor&limit |
read | task_page |
{ items: task[], next } |
GET /v1/tasks/{KEY} |
read | task_show |
task |
POST /v1/tasks |
write | task_create |
task, status 201 |
PATCH /v1/tasks/{KEY} |
write | task_status, task_title, task_parent, task_progress, task_touch, then task_show |
task |
GET /v1/events?to&from&kind&delivered&q&cursor&limit |
read | event_list |
{ items: event[], next } |
GET /v1/journal?scope&from&to&type&q&cursor&limit |
read | journal_page |
{ items: journal[], next } |
POST /v1/journal |
write | journal_add |
{ id }, status 201 |
POST /v1/messages |
write | message_send |
{ id, delivered }, status 201 |
GET /v1/sessions |
read | sessions |
session (the root of the tree) |
GET /v1/context?path&revision |
read | context_read |
context |
POST /mcp |
read or write, by token | MCP JSON-RPC (Streamable HTTP, stateless, JSON responses) | see docs/MCP.md |
Errors of /mcp are JSON-RPC (401 with WWW-Authenticate: Bearer, 405 on
GET/DELETE, 500 API token file is unreadable), not the {apiVersion} envelope.
Query details:
projecttakes a code ororg/project;statusa status code.deliveredistrueorfalse;kindone ofturn_end,waiting,milestone,message; the journal'stypeone ofnote,task_created,status_changed,title_changed,worktree_created,session_opened,message_sent,clone_prepared; itsfrom/toare UTC timestamps; itsscopeis a scope reference as in the CLI'sjournal -s:global, an org slug,org/project, ortype:keysuch asproject:FX,task:FX-1ororg:fx; all scopes by default. A bareFXorFX-1is refused.revisionis a full 40- or 64-hex commit.- Empty values are ignored; any parameter not listed for the route is refused.
Bodies are JSON objects; every field is a string unless noted, unknown fields are refused:
POST /v1/tasks:project,title,status,slug,source(the exact source text), optionalparent(the key of a parent task of the same project). No file sources, noadoptSource.PATCH /v1/tasks/{KEY}:status,title,parent(a task key ornullto clear),reason,progress,touch(onlytrue). At least one of status, title, parent, progress, touch;reasonis required with status, title or parent and refused without them;touchonly alone. Parts apply in the order status, title, parent, progress, touch; progress carries no artifacts.POST /v1/journal:scope,text(a note).scopetakes the same references as the journal query (global, an org slug,org/project,project:FX,task:FX-1).POST /v1/messages:to,text; sent from the token'ssender.
Response shapes:
project:{ code, org, slug, identity, repository, checkoutPath }flow:{ project, code, source, integration, taskBranch, prTarget, mergeMethod, release }task:{ key, project, number, title, parent, status, isTerminal, contextDir, worktreePath, createdAt, updatedAt }(parentis the key of the parent task ornull)event:{ id, from, to, kind, text, createdAt, deliveredAt }journal:{ id, at, scope: { type, key? }, type, text, artifacts: [{ path, commit }] }(commitisnullfor historical path-only artifacts)session:{ key, session: { agent, nativeId, state } | null, children: session[] }context:{ path, revision, text }
Reading events never marks them delivered; /inbox and the warmup work as before.
Tasks, events and the journal come newest first by id. limit is 1-200 (default 50).
next is an opaque cursor or null; pass it back as cursor for the following
page. q is a case-insensitive substring: tasks match key and title, events and
journal entries their text. Projects, sessions, flow and context are not paged.
The server keeps one read-only database connection and checks
PRAGMA data_version once a second. Each change committed by anyone (CLI, MCP,
agents, this server) increments changeId, a counter kept in the server process.
-
GET /v1/inforeturns the currentchangeIdand the server'sinstanceid. -
GET /v1/changesis a Server-Sent Events stream. It sends the current value at once and then one event per change:retry: 2000 id: 7 event: change data: {"changeId":7}A
: keepalivecomment follows every 25 s. -
Clients reread what they show when an event arrives.
changeIdis monotonic only within one process: wheninstancechanges, the server restarted and the client rereads everything.
apiVersion is 1. New fields and routes may appear in /v1; removing or renaming a
field or route needs /v2.
The server opens a database of schema 15 and keeps its code in memory. After an engine update or a migration, restart it.
One line per request on stderr:
2026-11-03T10:00:00.000Z GET /v1/tasks 200 phone
Time, method, path without the query, status and the token's name (- when no
token was accepted). Token values, query strings, headers and bodies are never
logged. An unparseable request target is logged with path -. When the token file
cannot be read, its raw error (with the file's path, never a token value) is written
as a separate stderr line: once per request, and at most once per poll tick while
change streams are checked.
Requests to /mcp are logged the same way, with path /mcp.
Not installed automatically. ~/.config/systemd/user/mypi-api.service:
[Unit]
Description=mypi HTTP API
[Service]
ExecStart=<node24> <engine>/dist/src/cli/main.js serve --listen <tailscale-ip>
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.targetsystemctl --user enable --now mypi-apiAdd Environment=XDG_STATE_HOME=… or Environment=XDG_CONFIG_HOME=… when the state
or the token file is not at the defaults.
The same mypi serve serves a read-only browser interface at
http://<listen>:<port>/ (http://127.0.0.1:8737/ by default).
- Sign in by pasting a token (read or write; in the browser both only read).
POST /v1/sessionsets the cookiemypi_session=<token>; Path=/; HttpOnly; SameSite=Strict; Max-Age=2592000; page scripts never see the token afterwards. Logout (DELETE /v1/session) clears it, andmypi token revokeends the session at the next request. Secureis added only when the request carriesX-Forwarded-Proto: httpsfrom an HTTPS proxy (for exampletailscale serve). Over plain http through Tailscale the cookie has noSecure: the tunnel encrypts, and withSecurethe browser would not send the cookie back over http.- CSRF: writes over the cookie are refused, login and logout require
Content-Type: application/json, which a cross-site form cannot send, and there is no CORS. - Static files: exact paths of
dist/ui; any other path without an extension outside/assets/falls back toindex.html(client routes), while/assets/*and paths with an extension that do not exist are 404. Hashed/assets/*are cached for a year (immutable), everything else isno-cache;index.htmlcarries a Content-Security-Policy (default-src 'self', inline styles only) andX-Frame-Options: DENY. Static requests are logged with token-. dist/uiis read once at startup: restartserveafterpnpm build.- Screens: projects (with their flow), tasks, events and the journal, each with
filters kept in the URL, search (
q) and a cursor "Load more". The UI rereads what it shows on every/v1/changesevent (a Live/Offline badge shows the stream), and a Refresh button rereads on demand. Unknown fields and values are shown as they come. The layout works down to phone width. - Development:
mise exec -- pnpm --filter mypi-web devstarts Vite with a proxy of/v1to127.0.0.1:8737.
src/http/serve.ts dispatches by path prefix through mounts: /v1/ (the API), /mcp (the remote MCP endpoint, MCP, same tokens and access log) and / (the web UI, MP-72), which stays last and catches every other path.