HTTP API
The LatchAI engine is a plain HTTP daemon. The desktop workbench is one client of it; a
curl in your terminal, a shell script, or a second window is exactly as much of a client.
Everything the UI can do, this API can do.
By default the engine listens on http://127.0.0.1:7777. LATCHAI_PORT moves the port;
LATCHAI_BIND moves the bind address (read the security note below before you touch it).
curl -s http://127.0.0.1:7777/api/status | jqBefore you call anything
Section titled “Before you call anything”This API is not authenticated for local callers. It can read and write mounted files, run shell through agents, and spawn MCP processes, so three layers stand in front of it instead:
- Loopback bind. The listener is on
127.0.0.1unlessLATCHAI_BINDsays otherwise. - Host allowlist. The
Hostheader must belocalhost,127.0.0.1or[::1](with or without a port). This is what defeats DNS rebinding, which walks straight past a loopback bind. - Origin allowlist. A browser attaches
Originto every cross-origin request. Only the engine’s own origin and the Vite dev server (:5173) are accepted. A request with noOriginis a local process —curl, a script, the CLI — and is allowed. That is why the examples on this page work with no credential at all.
A request that fails those checks gets 403 {"error": "forbidden: non-local host or origin"}.
Two credentials can substitute for the Host/Origin test:
- The mobile token. One shared secret per home, minted at boot and stored at
<latchHome>/mobile-token.json. A paired phone presents it as thex-latchai-mobile-tokenheader, or as?token=on the WebSocket (browsers give no way to set a header on a socket). The engine prints the pairing URL on startup. Anyone holding it has the whole API — see the phone app. - The browser-extension pairing token, read from
GET /api/browser. It authorises the extension’s own/browserWebSocket, which additionally requires a loopback socket peer and achrome-extension://origin.
Two things the mobile token deliberately does not open: /term (the terminal
WebSocket checks the socket peer for loopback independently — that endpoint is code
execution as you), and /api/relay/* (the remote-control surface re-checks and refuses it,
so a phone cannot widen its own access).
Conventions
Section titled “Conventions”Every response is JSON unless the row says otherwise. Three error shapes, one implementation:
| Status | Body | Means |
|---|---|---|
400 |
{"error": "…"} |
malformed request |
404 |
{"error": "…"} |
the thing genuinely is not there |
405 |
{"error": "method not allowed"} |
the path exists, not for this verb |
202 {"ok": true} means dispatched — a run, a build, a chat turn. Progress arrives on the
event stream, never in the response. Request bodies are capped at 32 MB
(50 MB for the binary upload endpoint).
Home & settings
Section titled “Home & settings”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/status |
Provider, provider list, every registered tool name, MCP status, whether embeddings are configured, and what is running right now | — |
GET /api/home |
Where the LatchAI home is | → {path} |
POST /api/home |
Relocate the home and exit so the supervisor restarts the engine against it | {path} → {ok, path, restarting} |
GET /api/settings |
App-level settings | → {chatMaxTurns, compactFraction, completions} |
PUT /api/settings |
Save them | {chatMaxTurns, compactFraction, completions} |
GET /api/config |
The provider map, never key material | — |
PUT /api/config |
Replace the provider map, store new keys as secrets, hot-swap the running providers | {providers[], defaultProvider} |
POST /api/test-provider |
Dry-run one provider config with a real minimal completion | {type, baseUrl, model, newKey?, name?} → {ok, …}; a failed test is ok: false, not an error status |
GET /api/tools |
Every registered tool for the picker: name, clipped description, source (built-in or the MCP server), group, available |
see Tools reference |
POST /api/cron/preview |
Parse a cron expression with the same parser the trigger service arms | {expr} → {ok, next[3], description}; a bad expression is ok: false at 200 |
POST /api/diagnostics |
Run the project’s typechecker | {root} → {diagnostics[]} |
POST /api/reindex |
Rebuild the semantic index behind search_code |
— |
POST /api/inline-edit |
The editor’s ⌘K rewrite: a snippet plus an instruction, back as replacement code | {code, instruction, lang?} → {code} |
POST /api/pick-folder |
Show the native macOS folder picker | → {path} or {canceled: true} |
Files & mounts
Section titled “Files & mounts”Paths are the explorer’s virtual paths: bare under the workspace, <mount>/<path> under
a mounted folder. Everything resolves through the same multi-root fence the file tools use,
so nothing here reaches outside the workspace and your mounts.
| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/files |
The whole tree: {mounts, files: [{path, size}], warnings?}. A root that cannot be walked degrades to a warning instead of failing the response |
capped at 1500 files per root |
GET /api/files/<path> |
Read one file | → {path, content} |
PUT /api/files/<path> |
Write one file | {content} |
PUT /api/files-bin/<path> |
Write raw bytes — what a drag-drop from Finder uses | body is the bytes, cap 50 MB → {ok, path, bytes} |
POST /api/files-op |
The explorer’s context menu | {op: "delete"|"rename"|"copy"|"mkdir"|"reveal", path, to?}; a workspace or mount root is never a valid target |
GET /api/mounts |
List mounted folders | — |
POST /api/mounts |
Mount a folder; re-arms the file watcher and reconnects ${mounts}-bound MCP servers |
{path, name?} |
DELETE /api/mounts/<name> |
Unmount | — |
GET /api/search |
Workspace-wide text search (⌘⇧F) across every root | ?q= (2 chars minimum), ?case=1, ?regex=1 → {results: [{path, matches: [{line, col, len, text}]}], truncated} |
Every git route takes ?root= (a mount name, or workspace — the default).
| Endpoint | Purpose |
|---|---|
GET /api/git/status |
Working-tree status for one root |
GET /api/git/repos |
Every repo discovered across the workspace and mounts |
GET /api/git/tree-status |
One status map for the whole explorer, so the tree colours its rows off a single fetch |
GET /api/git/diff |
The raw diff; ?staged=1 for the index |
GET /api/git/contextual-diff |
The diff the review surface shows; ?mode= (default auto) |
GET /api/git/file-diff |
One file; ?path=, ?oldPath=, ?mode= |
GET /api/git/files-diff |
Several files; repeat ?paths= per file (never comma-split — a path may contain a comma) |
POST /api/git/commit |
Commit; {message, root?} → {sha} |
POST /api/git/suggest-message |
Ask a model for a commit message; {root?} → {message} |
POST /api/git/review |
Ask a model to review the working tree; {root?} → {review} |
Workflows & runs
Section titled “Workflows & runs”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/workflows |
Every workflow. A rejected file still appears, carrying its error, so a broken automation never silently vanishes from the sidebar |
bare array |
PUT /api/workflows/<id> |
Save a workflow document | body must carry "formatVersion": 2; id must match the URL |
PUT /api/workflows/<id>/group |
Move a workflow into a folder — the one save path a rejected v1 document can still use | {group: string | null} |
DELETE /api/workflows/<id> |
Delete it | — |
POST /api/runs/<workflowId> |
Run it now. Goes through the trigger service, so a manual run respects the same one-run-per-workflow guard as a cron fire | {payload?} — a string, or an object a manual_trigger keeps as an object so {{trigger.key}} reaches into it. 202, or 404 unknown / 400 rejected / 409 already running |
GET /api/runs |
The last 100 runs, with live runs overlaid so a resumed run is not listed as interrupted | — |
GET /api/runs/<runId>/events |
That run’s full event log, replayed | → {events} |
POST /api/runs/<runId>/stop |
Cancel a run in flight. The abort signal is threaded through agent loops, HTTP fetches and every subworkflow/map child, so this stops the tree | 202; the run ends run.stopped, not run.failed |
POST /api/runs/<runId>/resume |
Continue an interrupted run under the same run id, appending to the same log | 202 {ok, runId, workflowId, restored, rerun, lossy, steps}. 409 if it is already running, was stopped on purpose, or ended |
POST /api/checkpoints/revert |
Roll the workspace back to a checkpoint | {sha} |
GET /api/report |
Spend summary | ?from=, ?to= (epoch ms, both optional) |
GET /api/report/runs |
Run-level rows for the Usage area | ?workflow ?agent ?model ?status ?source ?workItem ?from ?to ?limit ?offset |
GET /api/brain |
The provenance graph derived from your home | ?focus= ?depth= ?types=a,b ?since= |
Webhooks
Section titled “Webhooks”POST /hooks/<workflowId> fires the first webhook_trigger in that workflow — the
back-compatible address, so a URL already pasted into a GitHub settings page keeps meaning
what it meant. POST /hooks/<workflowId>/<nodeId> fires that specific trigger, checked
against its own config.secret.
The secret travels as the x-latchai-secret header, or ?secret= for callers that cannot
set headers. The request body (first 8 KB) becomes the run’s payload, and your
Content-Type rides along so a JSON body is parsed into a real value.
| Status | Means |
|---|---|
202 |
accepted, run dispatched |
400 |
the workflow file exists and the engine refuses it — the body says why |
403 |
bad or missing webhook secret |
409 |
the workflow is disabled, or this trigger is paused (the body says which) |
404 |
no such workflow, no such webhook node, or the URL has too many segments |
See Triggers for the node’s own config.
Gates & permissions
Section titled “Gates & permissions”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/gates |
What is waiting on a human | → {pending, orphaned}. orphaned are gates recorded on disk whose engine died: readable, not resolvable |
POST /api/gates/resolve |
Answer a review gate | {runId, nodeId, approved, note?}; 404 if no such pending gate |
GET /api/permissions |
Persisted allow/deny rules plus the unattended default | → {rules, unattended} |
POST /api/permissions/rule |
Add an always-rule | {key, decision: "allow"|"deny"} |
DELETE /api/permissions/rule |
Remove one | ?key= |
PUT /api/permissions/defaults |
What an otherwise-asking call does with nobody watching | {unattended: "allow"|"deny"} |
POST /api/permissions/resolve |
Answer a live tool-permission prompt | {runId, callId, decision, scope?: "once"|"always"} — always persists a rule for the derived key before unblocking |
The keys these rules match are documented in the Tools reference.
| Endpoint | Purpose | Notable fields |
|---|---|---|
POST /api/chat |
Send a turn | {sessionId, message, context?, provider?, images?, agent?, dashboard?, atlas?, permissionMode?} → 202; the reply streams as events |
GET /api/chat |
Every session | → {sessions} |
GET /api/chat/<sessionId> |
The transcript | ?view=full returns {transcript, busy, queued, …selection}; without it, {messages, busy} |
PUT /api/chat/<sessionId> |
Rename a conversation | {title} |
POST /api/chat/stop |
Stop the in-flight turn | {sessionId} |
PUT /api/chat/mode |
Move the permission picker mid-turn — this is what makes “Allow all tools” apply to a turn already running | {sessionId, permissionMode: "ask"|"allow"} |
PUT /api/chat/queue |
Edit a queued message | {sessionId, id, message} |
DELETE /api/chat/queue |
Cancel one | ?session=&id= |
Agents
Section titled “Agents”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/agents |
The picker’s list: name, description, memory, vault, group, model, tools |
bare array |
GET /api/agents/<name> |
The full definition, system prompt included | — |
PUT /api/agents/<name> |
Create or overwrite agents/<name>.md |
{description, system, model?, tools?, maxTurns?, memory?, vault?, group?, skills?, temperature?, topP?, maxTokens?, seed?, effort?, extraBody?} |
DELETE /api/agents/<name> |
Delete it | — |
GET /api/agents/order |
Sidebar display order | → {groups, agents} |
PUT /api/agents/order |
Set it | {groups: string[], agents: string[]} |
Skills
Section titled “Skills”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/skills |
Every discovered skill across all tiers | → {skills, includeUserTier} |
GET /api/skills/<name> |
One skill’s description and body | — |
PUT /api/skills/<name> |
Write workspace/.claude/skills/<name>/SKILL.md |
{description, body} — the workspace tier is the one LatchAI manages |
DELETE /api/skills/<name> |
Delete a workspace skill; other tiers are read-only here | — |
PUT /api/skills-config |
Include or exclude the user tier (~/.claude/skills) from agent prompts |
{includeUserTier} |
Memory
Section titled “Memory”A scope is either shared — the user-level folder every memory-enabled agent reads — or one
agent’s own name. Files are plain .md.
| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/memory |
Every scope and its files | → {scopes} |
GET /api/memory/<scope>/<file> |
Read one | → {content} |
PUT /api/memory/<scope>/<file> |
Write one | {content} |
DELETE /api/memory/<scope>/<file> |
Delete one | — |
POST /api/memory/consolidate |
Run a maintenance model pass over one scope | {scope}; a provider failure is a 500, because the request itself was fine |
Vaults
Section titled “Vaults”The vault name is always the first segment. See Vaults.
| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/vaults |
Every mounted vault with its note and inbox counts | — |
POST /api/vaults |
Make a folder into a vault and mount it in one call. A folder that already holds vault.json is adopted, never overwritten |
{name, path, schema?, git?} → 201. 409 if the name or path is already mounted |
GET /api/vaults/<vault>/index |
The schema plus every note’s metadata: type, folder, links, backlinks, unresolved links, aliases, inbox flag | — |
GET /api/vaults/<vault>/note |
One note | ?path= → {content, note, meta} |
PUT /api/vaults/<vault>/note |
Edit any note — the difference between this surface and the agent tool | ?path=, {content}; 404 rather than create |
POST /api/vaults/<vault>/notes |
Create a note, filed straight into its type’s folder (humans skip the inbox) | {name, type, content} |
GET /api/vaults/<vault>/search |
Search notes | ?q=, ?limit= (default 20) |
POST /api/vaults/<vault>/refile |
Change a note’s type, its name, or both. The old file name is kept as an alias, so existing [[wikilinks]] still resolve |
{path, type?, name?}; human-only |
POST /api/vaults/<vault>/inbox/accept |
File an inbox note into a folder | {file, folder?} |
POST /api/vaults/<vault>/inbox/reject |
Delete it | {file} |
POST /api/vaults/<vault>/capture |
Hand a scrap of text to a model that turns it into inbox notes | {text, agent?} |
Work items
Section titled “Work items”The board API behind Projects. The three mutations call the same
core functions the work_* tools call, so validation, teaching errors and the append-only
activity line are identical whether a person or an agent did it.
| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/work |
Every board, its config, lane counts and items | ?archived=1 for the archive |
GET /api/work/<project>/<KEY> |
One item in full | → {item} |
GET /api/work/<project>/<KEY>/links |
The runs, chats and commits attached to it | — |
POST /api/work/<project> |
Create | {kind, title, body?, status?, fields?, …} → 201 {ok, key, item} |
PUT /api/work/<project>/<KEY> |
Update | {status?, title?, body?, assignee?, labels?, estimate?, priority?, rank?, fields?, archived?} → {ok, change, item} |
POST /api/work/<project>/<KEY>/comment |
Append to the activity log | {text} |
Dashboards
Section titled “Dashboards”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/dashboards |
Every dashboard with when it last built, whether it is building, and how many widgets errored | — |
POST /api/dashboards/design |
Describe a dashboard in words and let a model derive the definition. This is the primary way dashboards are made | {description, id?} — pass id to change an existing one |
GET /api/dashboards/<id> |
Definition + latest document + running flag | — |
GET /api/dashboards/<id>/doc |
Just the published document | — |
GET /api/dashboards/<id>/source/<ref> |
The full data behind one widget source | text/plain |
POST /api/dashboards/<id>/run |
Build it now | 202; 409 if already building |
PUT /api/dashboards/<id> |
Save the structured definition | {name, icon?, description?, refresh?, paused?, agent?, layout, tools?, widgets?, slots[], prompt} |
DELETE /api/dashboards/<id> |
Delete it | — |
GET /api/widgets |
The widget catalog plus the renderer names | — |
PUT /api/widgets/<name> |
Edit a widget type — this is where “always show a line graph” is really configured | {renderer, label?, span?, description} |
DELETE /api/widgets/<name> |
Delete one | — |
Code Atlas
Section titled “Code Atlas”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/atlas |
Every map with its last build, view/unit counts, staleness and live progress | — |
POST /api/atlas/design |
Describe a map in words | {instruction | description, id?} |
POST /api/atlas/standard |
One-click: point at a folder, get the standard brief, no model call | {scope} — the same folder asked for twice returns the existing map |
GET /api/atlas/<id> |
Definition + document + staleness | — |
GET /api/atlas/<id>/doc |
Just the document | — |
GET /api/atlas/<id>/progress |
Live build counts — cheap enough to poll every second | — |
GET /api/atlas/<id>/card/<unit> |
One survey card, by unit id or content hash | — |
GET /api/atlas/<id>/source/<ref> |
The archived evidence behind a citation | text/plain |
POST /api/atlas/<id>/run |
Build it | 202; 409 if already building |
PUT /api/atlas/<id> |
Save the definition | {name, icon?, description?, scope[], agent?, views[], flows, prompt} |
DELETE /api/atlas/<id> |
Delete it | — |
Models & providers
Section titled “Models & providers”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/models |
Local-model status plus the OpenAI-compatible gateway’s status | — |
GET /api/models/resources |
What the machine has to spend on a model | — |
GET /api/models/hf-search |
Search Hugging Face | ?q= |
GET /api/models/hf-detail/<repo> |
One repo’s detail | — |
POST /api/models/download |
Start a download | {repo, dtype} (default q4f16) → 202 {ok, id} |
DELETE /api/models/download/<id> |
Cancel it | — |
POST /api/models/load |
Load a model into the worker | {repo} → 202; progress lands as model.* events |
POST /api/models/unload |
Unload | — |
DELETE /api/models/<repo> |
Delete a downloaded model | — |
POST /api/models/api |
Move the OpenAI-compatible gateway between local and network. Widening to network mints a bearer key first — the wider bind never exists without one |
{bind: "local"|"network"} |
OpenAI-compatible API
Section titled “OpenAI-compatible API”A separate listener, not routes on the daemon. That is deliberate: the network toggle
must never expose /api/* — file writes, shell-running workflows — only completions. Default
port 7778, bound to 127.0.0.1 until you widen it. Persisted under api in
latchai.config.json.
Loopback callers need no key. Any non-loopback caller must present
Authorization: Bearer <key>.
| Endpoint | Purpose |
|---|---|
GET /v1/models |
Every provider this gateway serves — external endpoints, downloaded local models (owned_by: latchai-local), default |
POST /v1/chat/completions |
Standard OpenAI shape: model, messages, tools, stream, max_tokens / max_completion_tokens, temperature, top_p, stop, presence_penalty, frequency_penalty, seed. Multimodal content parts flatten to text + images |
Tool definitions pass through to the model; tool execution stays with the caller. LatchAI’s own tool loop, filesystem and shell are unreachable from here.
Every completion is billed to an aux run, so tokens an external editor spends through LatchAI still show up in Usage.
curl -s http://127.0.0.1:7778/v1/chat/completions \ -H 'content-type: application/json' \ -d '{"model":"default","messages":[{"role":"user","content":"one sentence on cats"}]}' | jq -r '.choices[0].message.content'Database
Section titled “Database”<id> is a saved connection id (letters, digits, dot, dash, underscore).
| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/db |
Saved connections | → {connections} |
POST /api/db/test |
Test an unsaved definition — the modal’s Test button | the connection def as the body |
PUT /api/db/connections/<id> |
Create or update. The path is the identity; a body claiming another id is ignored | the connection def |
DELETE /api/db/connections/<id> |
Delete it | — |
POST /api/db/connections/<id>/test |
Test a saved one | — |
GET /api/db/<id>/schema |
Tables, views, columns, keys. Cached 60 s | ?refresh busts the cache |
POST /api/db/<id>/query |
Run SQL | {sql, rowLimit?, mode?} — mode: "explain" runs the plan instead. A human here is governed by the connection’s own writable flag and nothing else |
GET /api/db/<id>/history |
This connection’s query history | — |
POST /api/db/<id>/table-data |
The table browser’s paged select | {schema?, table, sort?, filters?, offset, limit} (limit defaults to 100) |
POST /api/db/<id>/update-cell |
Edit one cell | {schema?, table, pk, set} → {ok, affected} |
POST /api/db/<id>/export |
Stream a result set out | {sql, format: "csv"|"json"|"ndjson"|"sql", table?} — sql format needs table. Answers the file, not JSON |
Secrets
Section titled “Secrets”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/secrets |
Names only, never values, plus which backend is in use | → {names, backend} |
POST /api/secrets |
Set one | {name, value} |
DELETE /api/secrets/<name> |
Delete one | — |
| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/mcp |
The mcp.json server map, live connection status, and any pending OAuth authorization URLs |
→ {servers, status, authUrls} |
PUT /api/mcp |
Replace the map and reconnect the pool in place — no restart | {servers}. A server may carry newToken / clearToken: the token is stored as a secret and only a {{secret:…}} header lands in mcp.json |
POST /api/mcp/<name>/toggle |
Enable or disable one server immediately | {enabled} |
POST /api/mcp/<name>/logout |
Drop an OAuth server’s tokens and client registration; it lands back in “needs authorization” | — |
GET /oauth/callback/<name> |
Where an HTTP MCP server sends your browser after authorization. Answers HTML, not JSON — it is a page a human lands on | ?code=, ?error= |
Notifications
Section titled “Notifications”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/notifications |
Newest-first list plus the unread count | → {notifications, unreadCount} |
POST /api/notifications/read |
Mark one read, or all of them | {id} for one; an empty body marks everything |
POST /api/notifications/<id>/dismiss |
Remove one | — |
Version history
Section titled “Version history”<kind> is one of agent, skill, workflow, dashboard, widget, atlas.
| Endpoint | Purpose |
|---|---|
GET /api/history/<kind>/<name> |
Every recorded version |
GET /api/history/<kind>/<name>/<version> |
One version’s content |
POST /api/history/<kind>/<name>/<version>/restore |
Restore it. The current state is snapshotted first, so a restore is itself undoable |
See Version history.
Editor assistance
Section titled “Editor assistance”Both of these are built around the same rule: nobody asked for the request, so nobody may be
interrupted by it failing. Every refusal and every failure comes back 200.
| Endpoint | Purpose | Notable fields |
|---|---|---|
POST /api/complete |
Ghost-text completion at the cursor | {path, prefix, suffix} → {text} or {text: null} for “no suggestion”. 15 s deadline, 256 tokens, 10 lines maximum |
POST /api/apply |
Turn a code block in the chat transcript into a proposed edit | {path, snippet, base?} → {merged, tier} or {merged: null, reason}. Tier splice is a free exact merge; tier model is the fallback. Nothing is written to disk — the answer goes back as an accept/reject diff |
base is the browser’s current buffer, which may be dirty. Only when the body carries no
base does the engine read the file.
Browser extension
Section titled “Browser extension”| Endpoint | Purpose | Notable fields |
|---|---|---|
GET /api/browser |
Connection status, the pairing token (minted on first read), and the folder to load unpacked | one fetch, because the panel renders all three together |
POST /api/browser/token/rotate |
New token; the live extension is dropped | — |
See Browser automation.
Remote control & the phone (internal)
Section titled “Remote control & the phone (internal)”These exist for the phone app and the relay connector. They are listed for completeness, not as an integration surface — the pairing model behind them is a prototype.
| Endpoint | Purpose |
|---|---|
GET /api/relay |
Relay status, the pairing blob when it is on, and paired devices. Desktop-only: refuses the mobile token |
POST /api/relay/enable / disable |
Start or stop the outbound connector at runtime |
POST /api/relay/remote-start |
{enabled} — may a paired phone start work? |
POST /api/relay/devices/<sid>/unpair |
Forget one phone, and its push subscription with it |
GET /api/mobile/session |
The phone’s handshake |
GET /api/mobile/approvals |
Everything waiting on a human, flattened for a small screen. Resolving still goes through /api/gates/resolve and /api/permissions/resolve |
GET /api/mobile/push/config |
The VAPID public key |
POST / DELETE /api/mobile/push/subscribe |
Register or forget one phone’s wake-up channel |
GET /m/ |
The built phone app itself (the shell is served unauthenticated; the token guards the data) |
The event stream
Section titled “The event stream”GET ws://127.0.0.1:7777/events — one WebSocket carrying every event the engine emits, as
one JSON frame per event. This stream is the product’s memory: the live canvas, the run
monitor, chat, and the review surface all read the same frames. Nothing is polled.
There are no filters and no server-side replay. Every open socket receives every frame. That is a deliberately small contract, and it puts catch-up in the client’s hands. The workbench’s own sequence, which is worth copying:
GET /api/status— what is running right now.GET /api/runs, thenGET /api/runs/<id>/eventsfor each run still in flight.GET /api/gatesandGET /api/notificationsto seed what is waiting.- Then open the socket.
Catch-up first, socket second: the log and the live stream carry the same events, so overlapping them would double-apply. Do the same on every reconnect — a reconnect usually means the daemon restarted underneath you.
A paired phone appends ?token=<mobile token>, because a browser cannot set a header on a
WebSocket.
Event kinds
Section titled “Event kinds”Runs and nodes:
run.started, run.completed, run.failed, run.stopped, run.interrupted,
node.started, node.delta, node.completed, node.retry, node.failed, node.skipped,
state.updated, trigger.skipped, workflows.changed.
Tools and side effects:
tool.call, tool.result, file.read, file.written, file.edited, shell.exec,
web.fetch, browser.action, db.query, plan.update.
Chat:
chat.message, chat.delta, chat.reasoning, chat.status, chat.context,
chat.compacted, chat.queued, chat.queue.updated, chat.queue.removed.
Human decisions:
gate.waiting, gate.resolved, permission.requested, permission.resolved,
permission.denied, checkpoint.created.
Accounting and models:
token.usage, model.retry, model.loading, model.loaded, model.unloaded,
model.stats, model.worker.crashed, model.download.started, model.download.progress,
model.download.completed, model.download.failed, model.download.canceled.
Published artefacts and the inbox:
dashboard.published, atlas.published, work.updated, notification.
Three more frames ride the socket but are never written to a run log, because they are
UI synchronisation rather than run history: fs.changed (files moved under a watched root),
config.reloaded (latchai.config.json changed on disk and providers were rebuilt), and
chat.renamed.
trigger.skipped and workflows.changed carry no runId, so they reach the live stream but
land in no run’s log either.
Other sockets
Section titled “Other sockets”ws://127.0.0.1:7777/term?id=<id>&cols=<n>&rows=<n>— the workbench’s terminal panel. Requires a loopback socket peer, whateverLATCHAI_BINDsays: everything else on this page is data, this one is code execution as you.ws://127.0.0.1:7777/browser— the extension bridge. Loopback peer plus achrome-extension://origin. (internal)
Worked examples
Section titled “Worked examples”Run a workflow with a payload
Section titled “Run a workflow with a payload”payload may be a string or an object. An object is what the keyed-parameters run form
sends, and a manual_trigger keeps it as an object so {{trigger.repo}} reaches into it.
curl -s -X POST http://127.0.0.1:7777/api/runs/nightly-report \ -H 'content-type: application/json' \ -d '{"payload": {"repo": "loom", "since": "2026-09-01"}}'# → 202 {"ok":true}The response says nothing about the run — progress is on the event stream. To watch it:
websocat ws://127.0.0.1:7777/events | jq -c 'select(.kind|startswith("run.") or startswith("node."))'A 409 means that workflow is already running; a 400 means the file on disk is there and
the engine refuses it — the body is the sentence that says what to fix.
Resolve a review gate
Section titled “Resolve a review gate”curl -s http://127.0.0.1:7777/api/gates | jq# → {"pending":["run-1757426531123-4:review"],"orphaned":[]}
curl -s -X POST http://127.0.0.1:7777/api/gates/resolve \ -H 'content-type: application/json' \ -d '{ "runId": "run-1757426531123-4", "nodeId": "review", "approved": true, "note": "diff looks right" }'# → {"ok":true}A pending key is <runId>:<nodeId> — split on the last colon, since run ids become
filenames and never contain one. approved: false rejects the gate and the run takes its
rejection path. A 404 means the gate is no longer pending; if it appears under orphaned
instead, its engine died and there is nobody left to answer — resume the run and it will ask
again for real.
List runs
Section titled “List runs”curl -s http://127.0.0.1:7777/api/runs | jq -r '.[] | "\(.status)\t\(.workflowId)\t\(.runId)"' | headNarrow it by spend and outcome through the report index instead:
curl -s 'http://127.0.0.1:7777/api/report/runs?status=failed&limit=20&from=1757376000000' | jqAnd replay one run exactly as the canvas does:
curl -s http://127.0.0.1:7777/api/runs/run-1757426531123-4/events | jq '.events[].kind'