Skip to content

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).

Terminal window
curl -s http://127.0.0.1:7777/api/status | jq

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.1 unless LATCHAI_BIND says otherwise.
  • Host allowlist. The Host header must be localhost, 127.0.0.1 or [::1] (with or without a port). This is what defeats DNS rebinding, which walks straight past a loopback bind.
  • Origin allowlist. A browser attaches Origin to every cross-origin request. Only the engine’s own origin and the Vite dev server (:5173) are accepted. A request with no Origin is 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 the x-latchai-mobile-token header, 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 /browser WebSocket, which additionally requires a loopback socket peer and a chrome-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).

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).

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}

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}
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=

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.

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=
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[]}
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}

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

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?}

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}
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
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
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"}

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.

Terminal window
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'

<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
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=
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

<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.

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.

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.

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)

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:

  1. GET /api/status — what is running right now.
  2. GET /api/runs, then GET /api/runs/<id>/events for each run still in flight.
  3. GET /api/gates and GET /api/notifications to seed what is waiting.
  4. 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.

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.

  • ws://127.0.0.1:7777/term?id=<id>&cols=<n>&rows=<n> — the workbench’s terminal panel. Requires a loopback socket peer, whatever LATCHAI_BIND says: 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 a chrome-extension:// origin. (internal)

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.

Terminal window
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:

Terminal window
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.

Terminal window
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.

Terminal window
curl -s http://127.0.0.1:7777/api/runs | jq -r '.[] | "\(.status)\t\(.workflowId)\t\(.runId)"' | head

Narrow it by spend and outcome through the report index instead:

Terminal window
curl -s 'http://127.0.0.1:7777/api/report/runs?status=failed&limit=20&from=1757376000000' | jq

And replay one run exactly as the canvas does:

Terminal window
curl -s http://127.0.0.1:7777/api/runs/run-1757426531123-4/events | jq '.events[].kind'