Skip to content
HTTP API Reference

HTTP API Reference

Loop exposes a lightweight HTTP API for managing channels, threads, messages, tasks, tickets, files, memory, and real-time events. All endpoints are prefixed with /api/.

Related docs: Terminal WebSocket | Events System | Memory System | Kanban Panel

General

  • Authentication: Every route except the few listed under Authentication needs a token. See that section.
  • CORS: All responses include Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS, and Access-Control-Allow-Headers: Content-Type, Authorization. Preflight OPTIONS requests return 204 No Content. CORS grants nothing on its own: without a token a page on another origin only gets 401s.
  • Content-Type: JSON endpoints return application/json. File-reading endpoints return text/plain; charset=utf-8.
  • Error responses: Plain text body with the appropriate HTTP status code.

Common Error Codes

CodeMeaning
400Bad request – missing/invalid parameters or request body
401Missing or invalid API token
403The token is valid but may not make this request (an agent token on an owner-only route, or naming another project)
404Resource not found
413Request entity too large (file operations)
500Internal server error
501Feature not configured (service dependency is nil)
503Service unavailable (commands not configured)

Authentication

Every API caller is either the owner or an agent. Nothing is trusted for where it comes from: no cookies, no origin checks, and localhost gets no pass.

Tokens

CallerTokenWhere it lives
Owner: the desktop app, the loop CLI, host toolsOwner token, 32 random bytes in hexapi-token in Loop’s directory under the OS user config dir (~/Library/Application Support/loop/ on macOS, ~/.config/loop/ on Linux). Dir 0700, file 0600. loop serve creates it on first start
Agent: the clients inside one agent container (the MCP server, mcp-browser, loop review)Agent token, issued per container/run/loop/api-token in the container, readable by the agent user only. Never in the container’s environment, so docker inspect doesn’t show it. Revoked when the container goes

The owner token’s directory is never a channel dir and never mounted into a container, whatever the config says.

Send the token as a header:

Authorization: Bearer <token>

Browsers can’t set headers on a WebSocket, so WebSocket clients send it as a subprotocol instead: Sec-WebSocket-Protocol: loop, loop.token.<token>. The server answers with loop.

Rotating. loop api:rotate-token, Developer → Rotate API Token… in the desktop app, or POST /api/auth/rotate with the owner token writes a new owner token and the daemon switches to it at once. The CLI reads the file on every request and the desktop app reads it again on its next 401, so both carry on without a restart. A browser tab opened with loop app:url needs a fresh URL. Agent tokens are unaffected.

Browser mode. A UI running in a plain browser tab, without the desktop app’s preload, has no file to read. loop app:url prints the UI’s URL with the token in the fragment (#loop_token=...); the UI moves it to the tab’s sessionStorage and drops it from the address bar. Fragments aren’t sent to the server, but anyone with the URL has full access, so treat it as a secret.

Agent scope

An agent token works only on the routes in-container clients call (the table is agentRoutes in internal/api/agent_scope.go): messages, threads, tasks, shortcuts, memory, PR/MR status (GET /api/channels/{id}/pr/status), playground, workflows, learn proposals, review, quality, the UI bridge (GET /api/ui/state, POST /api/ui/commands), and the browser action. Every other route answers 403, among them config, terminals, gates, images, token rotation and content links.

On the routes it may call, an agent is held to its own project. Every channel, thread, task, workflow run and dir_path a request names, in the path, the query or the JSON body, must be the agent’s own channel or dir, or share its project root (after symlinks). A request that names anything else gets 403. The exception is POST /api/messages (the send_message tool): its channel_id may be any channel, so one agent can hand work or news to another project’s channel. Agents may also only change project bash shortcuts, and can’t start workflows while workflow_bash_local runs workflow bash on the host. The routes that change review comments (delete, edit, assess a thread, push one, push all) are held to the agent’s own channel, not its project: they act on the PR as the user. An agent may not delete a GitHub comment, and may not approve a PR (POST .../review/approve is owner only).

On the UI bridge, an agent’s command must start with select_channel to a channel of its project, and every select_channel in it must name a channel of the project. GET /api/ui/state leaves out the state of windows showing a channel outside it. The UI WebSocket stays owner-only.

Listings an agent makes, such as a channel’s sessions with the ids imported into other channels, name only its project’s items, and a refusal names what the request named: another project’s task or run is refused by its id, not its channel. agent_scope_spec_test.go checks this over a daemon serving two projects: every agent route, with each of the other project’s ids in each place a handler reads one, shows and changes nothing of the other project’s (GET /api/channels lists every channel by design).

Public routes

RouteWhy
GET /api/healthLiveness checks before a token is at hand
POST /api/gate/container-approvalChecks its own per-container gate token, which only root processes in the container can read
GET /c/{cap}/{path...}Content links, below

POST /api/auth/rotate

Owner only. Writes a new owner token and switches to it. Returns 204 No Content.

POST /api/content-caps

Owner only. Iframes, images, video and <base href> can’t send an Authorization header, so the UI loads them through a short-lived signed link instead. Returns a base URL that serves one channel root’s files, one playground or one plugin’s panels.

Request body:

FieldTypeDescription
kindstringraw (a channel root’s files), playground or plugin (a plugin’s panel bundle)
channel_idstringraw: the channel (required). playground: set for a project playground, empty for a global one
rootintraw: the channel root index
namestringplayground: its name (required). plugin: the plugin’s id (required)

Response (200):

{"base_url": "/c/<cap>/", "expires_in_sec": 3600}

The link is signed with a key that lives only in the running daemon, so links stop working when it restarts. The UI mints a new one well before expiry.

GET /c/{cap}/{path...}

Serves path under what the link names, like GET /api/channels/{id}/raw/{root}/{path...} and the playground routes do, with the same path checks: a path that leaves the root, symlinks included, gets 400. A bad or expired link gets 403 link expired or invalid. Playground pages are sent with Content-Security-Policy: sandbox allow-scripts allow-forms allow-modals allow-popups allow-downloads, which gives them an opaque origin: agent-written JavaScript can’t read the UI’s storage even when both are served from the same origin.

A plugin link serves the files under the plugin’s /loop-plugin/ui/, so path starts with ui/, as a panel’s entry does. It answers 403 unless the plugin may run and the owner approved ui.panel. Files are sent with Cache-Control: no-store and a policy that sandboxes the page the same way and lets it load scripts, styles, images, fonts and fetches only from the link itself (plus data:/blob: for images, fonts and media), with no inline scripts, frames or form submission.


Health

GET /api/health

Returns server health status.

Response (200):

{"status": "ok"}

Channels

GET /api/channels

List all channels with optional filtering. Enriches each channel with container running status, agent running status, and current git branch.

Query Parameters:

ParamTypeDescription
querystringFilter channels by name (case-insensitive substring match)
platformstringFilter by platform (discord, slack, local)

Response (200):

[
  {
    "channel_id": "abc123",
    "name": "my-project-a1b2",
    "dir_path": "/home/user/projects/my-project",
    "display_path": "~/projects/my-project",
    "parent_id": "",
    "active": true,
    "container_running": true,
    "agent_running": false,
    "review_running": false,
    "branch": "main",
    "commit": "abc1234",
    "worktree": false,
    "locked": false,
    "last_activity_at": "2026-03-25T14:30:00Z"
  }
]

Behavior notes:

  • When a channel has no dir_path, falls back to ~/.loop/{channel_id}/work.
  • display_path is dir_path with the daemon user’s home dir shown as ~, for display; the header shows it.
  • container_running is determined by querying the Docker daemon for running containers.
  • agent_running indicates whether an active Claude agent run exists for the channel. A run is registered before its agent.status “running” event is broadcast, so a response to a request made after that event reflects the run.
  • review_running is true while a review run or a dedup pass runs on the channel’s review session (its status is reviewing, or verifying during the verification step ). Review sessions live in the daemon’s memory, so this comes from the review store, not the database. The sidebar shows the channel as running and patches the flag live from review.status events.
  • review_max_iterations is the project’s review.max_iterations (global, then project and worktree config; 3 unless set), where the Review panel’s pass count starts.
  • branch is resolved by running git rev-parse --abbrev-ref HEAD in the channel’s directory.
  • commit is the short commit hash from git rev-parse --short HEAD, and subject its subject line.
  • upstream is the branch’s tracking branch (e.g. origin/main), with ahead / behind counting the commits between them. Omitted when there’s none.
  • sync_base is set on a worktree thread whose base branch still resolves, with base_ahead / base_behind counting the commits between the checkout and it.
  • worktree is true for threads created via POST /api/worktrees.
  • root_dir_path is set on rows inside a worktree chain — the worktree thread itself, a thread under it (e.g. a scheduled task’s), or a worktree cut from another worktree — and holds the dir_path of the non-worktree checkout the chain was cut from. Omitted everywhere else. The Kanban panel uses it for its Local/Root board switch.
  • model_override / effort_override are the model and effort picked for the channel (see PATCH /api/channels/{id}/agent-config ). Omitted when it inherits the config’s.
  • last_activity_at is when the channel’s newest message was written, or when its review session last changed if that’s later, since a review writes no messages. Omitted when it has neither, or when the message lookup fails and there’s no review session (the list is still returned). The sidebar’s Recent section sorts by it.
  • description is what the channel or thread is for, set via POST /api/channels/{id}/description . Omitted when empty.
  • ticket_url is the URL of the channel or thread’s ticket, set via POST /api/channels/{id}/ticket . Omitted when unset.
  • trust_pending is true when the project config’s host-reaching fields changed since you last trusted them, so they don’t apply yet (see GET /api/config/project/trust ). For a worktree chain it’s the root checkout’s config. Omitted otherwise, and when the config can’t be read.
  • pr_attention is what the thread’s PR/MR needs (see PR watching ): failed for a failure no agent is fixing, which lights the sidebar’s ci pill, fixing while the thread’s agent was sent to fix one (ci…), or conflict for a merge conflict with the base (cnf), which is always the owner’s to resolve. Omitted otherwise, and when PR watching is off. The sidebar patches it live from channel.pr events.
  • unresolved_threads counts the PR/MR’s unresolved review threads, which light the sidebar’s cmt pill. Omitted when there are none, and when PR watching is off. Patched live from channel.pr events.
  • plugin_badges are the badges plugins set on the row with PUT /api/plugin/v1/badge , as [{"plugin_id", "text", "title"?}] in plugin id order. Omitted when there are none, when plugins are off, or when the lookup fails. The sidebar patches them live from plugin.badge events.
  • task_id is set on a thread a scheduled task created for its output: the id of that task. Omitted on every other channel and thread, including ones that host tasks. The sidebar’s hide-task-threads toggle filters on it.
  • locked is true when the channel/thread is guarded against accidental deletion (toggle via PATCH /api/channels/{id}/lock ). DELETE /api/channels/{id} and DELETE /api/threads/{id} return 409 Conflict while a row is locked.
  • Hidden learn and explain threads (kind: "learn" or "explain", see Learn and Explain ) are left out of the list.

Errors: 501 if channel listing is not configured.


POST /api/channels

Ensure a channel exists for the given directory path. If a channel already maps to the directory on the specified platform, its ID is returned. Otherwise, a new channel is created on the chat platform and stored in the database.

Request:

{
  "dir_path": "/home/user/projects/my-project",
  "platform": "discord"
}
FieldTypeRequiredDescription
dir_pathstringyesAbsolute path to project directory
platformstringnoTarget platform (discord, slack, local)

Response (200):

{"channel_id": "abc123"}

Behavior notes:

  • Channel name is derived from filepath.Base(dir_path), sanitized to lowercase alphanumeric/hyphens/underscores, with a random hex suffix.
  • On Discord/Slack platforms, invites the bot owner and sets the channel topic to the directory path.

Errors: 400 if dir_path is empty. 501 if channel creation is not configured.


POST /api/channels/create

Create a new channel with an explicit name.

Request:

{
  "name": "my-channel",
  "author_id": "user123",
  "channel_id": "source_channel_for_platform_lookup",
  "platform": "local"
}
FieldTypeRequiredDescription
namestringyesChannel display name
author_idstringnoUser to invite to the new channel
channel_idstringnoSource channel for platform/guild inference
platformstringnoTarget platform

Response (201):

{"channel_id": "abc123"}

Errors: 400 if name is empty. 501 if channel creation is not configured.


POST /api/channels/ensure-all

Ensure a channel exists for the given directory on all configured platforms.

Request:

{"dir_path": "/home/user/projects/my-project"}

Response (200):

[
  {"platform": "discord", "channel_id": "abc123", "created": false},
  {"platform": "slack", "channel_id": "C0123456", "created": true}
]

Errors: 400 if dir_path is empty. 501 if channel creation is not configured.


DELETE /api/channels/{id}

Delete a channel and all its child threads.

Path Parameters:

ParamTypeDescription
idstringChannel ID to delete

Response: 204 No Content

Behavior notes: Deletes child threads (channels with matching parent_id) before deleting the channel itself. Removes the MCP config files of the channel, its threads and their hidden learn and explain threads (.loop/mcp-<id>.json, and .loop/mcp-<id>-learn.json / .loop/mcp-<id>-explain.json for a hidden thread, each in its own dir), unless keep_mcp_configs is set for the channel’s project. Cancels a learn pass or explanation still running or queued, deletes the hidden threads’ forked session files, and removes the channel’s containers.

Errors: 404 if channel not found. 409 if the channel (or any of its threads) is locked. 501 if channel deletion is not configured.


PATCH /api/channels/{id}/lock

Toggle the locked flag on a channel or thread. Locking guards against accidental UI deletes — an unlock is required before the corresponding DELETE /api/channels/{id} or DELETE /api/threads/{id} will succeed (they return 409 Conflict otherwise).

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Request:

{"locked": true}
FieldTypeRequiredDescription
lockedboolyesNew locked state

Response: 204 No Content

Behavior notes: Broadcasts a channel.locked event with the new state so other clients update their sidebar/menus.

Errors: 404 if channel not found. 501 if channel locking is not configured.


POST /api/channels/{id}/rename

Rename a channel, thread or worktree thread’s display name. Only the name changes — the directory path, git branch and Claude sessions are untouched.

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Request:

{"name": "New Name"}
FieldTypeRequiredDescription
namestringyesNew display name

Response: 200 OK

{"channel_id": "abc123", "name": "New Name"}

Behavior notes: Broadcasts a channel.updated event carrying the new name so other clients refresh their sidebar live.

Errors: 400 if name is empty. 404 if channel not found.


POST /api/channels/{id}/description

Set a channel, thread or worktree thread’s description, shown in the sidebar’s row info popup. An empty description clears it.

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Request:

{"description": "Fixes the login redirect"}
FieldTypeRequiredDescription
descriptionstringnoThe description, trimmed; at most 500 characters. Empty or missing clears it.

Response: 200 OK

{"channel_id": "abc123", "description": "Fixes the login redirect"}

Behavior notes: Allowed on locked rows. Broadcasts a channel.updated event carrying only the new description, so other clients refresh their sidebar live.

Errors: 400 if the body isn’t valid JSON or the description is longer than 500 characters. 404 if channel not found. 501 if not configured.


POST /api/channels/{id}/ticket

Link a channel, thread or worktree thread to its ticket in any tracker (Jira, GitHub, Linear, …), shown in the sidebar’s row info popup. An empty ticket_url clears it.

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Request:

{"ticket_url": "https://example.atlassian.net/browse/PROJ-123"}
FieldTypeRequiredDescription
ticket_urlstringnoThe ticket’s URL, trimmed: absolute, http or https, with a host, at most 2048 characters. Empty or missing clears it.

Response: 200 OK

{"channel_id": "abc123", "ticket_url": "https://example.atlassian.net/browse/PROJ-123"}

Behavior notes: Allowed on locked rows. Broadcasts a channel.updated event carrying only the new ticket_url, so other clients refresh their sidebar live.

Errors: 400 if the body isn’t valid JSON, or the URL isn’t an absolute http(s) URL or is longer than 2048 characters. 404 if channel not found. 501 if not configured.


Threads

POST /api/threads

Create a new thread under a parent channel. If the channel ID points to a thread, resolves to its parent channel automatically.

Request:

{
  "channel_id": "parent_channel_id",
  "name": "Thread title",
  "author_id": "user123",
  "message": "Initial message for the thread"
}
FieldTypeRequiredDescription
channel_idstringyesParent channel ID
namestringyesThread display name
author_idstringnoThread creator’s user ID
messagestringnoInitial message content

Response (201):

{"thread_id": "thread_abc123"}

Behavior notes:

  • When an IncomingMessageHandler (orchestrator) is configured, the initial message is not stored via CreateThread. Instead, HandleThreadCreated is called asynchronously to store it as a user message and trigger the agent.
  • Broadcasts a channel.created event to the parent channel via the EventsHub.
  • Thread inherits the parent’s dir_path, session_id, permissions, guild_id, and platform.

Errors: 400 if channel_id or name is empty. 501 if thread creation is not configured.


POST /api/threads/{id}/fork

Forks a thread: creates a sibling thread that continues the source thread’s conversation. The new thread copies the source’s Claude session id (marked fork-pending) and imports its history for display; the orchestrator runs the fork’s first message with --fork-session, so the two threads diverge instead of clobbering each other — the SOURCE thread keeps its session untouched.

For worktree threads, the fork additionally creates a new git worktree branched from whatever the source worktree has checked out: its current branch, whatever it’s named, or its commit when HEAD is detached. It starts from the committed state; uncommitted changes stay behind. The new thread’s base_branch is set to that branch (or commit) so its diff shows only the fork’s own delta. The source’s transcript is copied from the source worktree’s own Claude project dir (a worktree thread’s transcripts live there, not under the parent channel’s) into the new worktree’s, and its history is imported from there. When the source has no session, or its transcript is gone, the fork starts with no session rather than the parent channel’s.

Response (201):

{ "thread_id": "abc123", "worktree_path": "/path/to/.worktrees/wt-1a2b" }

worktree_path is present only for worktree forks. Errors: 400 when the id is not a thread or is a hidden learn thread (thread not found); 500 on git/store failures.


POST /api/channels/{id}/messages/{msgId}/fork

Forks a channel’s or thread’s conversation at one of its messages: like POST /api/threads/{id}/fork , but the new thread’s session is cut at that message. msgId is the message’s msg_id. The fork of a top-level channel is a new thread under it, and the fork of a thread is a sibling thread.

  • Agent reply: the reply must record where it sits in its session’s transcript; such messages carry "forkable": true in the message list, the timeline and message.created events. The new thread holds the reply’s session with fork_pending and the reply’s transcript uuid. Its first run resumes with --fork-session --resume-session-at=<uuid>, so it keeps the reply and drops everything after it. See Orchestrator: Fork at a turn .
  • User message: Claude Code doesn’t report a prompt’s uuid, so Loop finds it when the message’s run ends: it walks the transcript back through parentUuid from the run’s first reply that records a uuid to the prompt, and records the prompt’s uuid on the message. The fork cuts at the entry before the prompt. A message from before Loop recorded prompts is located the same way at fork time, from its first reply that records a uuid. When the prompt started its session, the new thread starts with no session. Either way, the response’s prompt is the message’s text, for the composer to offer again.

The history up to the cut is imported into the new thread for display. Worktree threads also get a new worktree, as in the thread fork.

Response (201):

{ "thread_id": "abc123", "prompt": "the user message, for a fork at one" }

worktree_path is present for worktree forks, and prompt only for a fork at a user message.

Errors:

  • 404: the channel doesn’t exist or is a hidden learn or explain thread, or it has no such message.
  • 409: the message can’t be forked at. This covers a reply with no transcript position, a user message with no such reply, and a transcript that is missing or doesn’t contain the reply.
  • 500: git or store failures.
  • 501: not configured.

DELETE /api/threads/{id}

Delete a thread.

Path Parameters:

ParamTypeDescription
idstringThread ID to delete

Response: 204 No Content

Behavior notes: Deletes from the chat platform (if a creator is configured) and removes from the database, then cancels the runs of its hidden learn and explain threads, deletes their forked session files and removes the MCP config files of the thread and those threads, unless keep_mcp_configs is set for the parent channel’s project. If the thread has an associated git worktree, the worktree and its branch are cleaned up automatically.

Errors: 409 if the thread is locked (toggle via PATCH /api/channels/{id}/lock ). 501 if thread deletion is not configured.


Messages

POST /api/messages

Send a message to a channel. When an orchestrator is configured, routes through it asynchronously so Claude can respond.

Request:

{
  "channel_id": "abc123",
  "content": "Hello, bot!",
  "mode": "plan",
  "interrupt": false,
  "delay_seconds": 0
}
FieldTypeRequiredDescription
channel_idstringyesTarget channel or thread ID
contentstringyesMessage text
modestringnoAgent mode hint (e.g. "plan")
interruptboolnoWhen true, cancels the active run on the channel and inserts this message with priority = MaxQueuedPriority(channel_id) + 1 so it claims next ahead of any queued rows. Existing queued messages are preserved (not deleted). Used by the chat UI’s “Deny with prompt” gate flow.
delay_secondsintnoWhen > 0, holds the message back for that many seconds. The row is inserted with not_before = now + delay_seconds (unix seconds); ClaimNextPending skips it until then and a background poller drains the channel once it comes due. Takes precedence over interrupt (an interrupt would be pointless if the message runs later). Set by the queue_message MCP tool.

Response: 204 No Content, or with delay_seconds > 0, 200 OK with the queued message’s id, so the caller can remove it with DELETE /api/messages/{id} before it runs:

{"msg_id": "ask-3f2a…"}

Behavior notes:

  • When an IncomingMessageHandler is set, the message is dispatched asynchronously with a detached context (the HTTP response returns immediately).
  • When no handler is set, falls back to direct PostMessage via the configured message sender.
  • interrupt=true requires both a RunCanceller and a Store on the server; the orchestrator wires both during startup.
  • delay_seconds > 0 routes through HandleIncomingMessageDelayed with a msg_id picked by the handler, which stamps not_before on the inserted row. Because the row is not yet due, the immediate drain claims nothing; the orchestrator’s delay poller re-drains the channel once not_before passes (this also recovers pending delays across a daemon restart, since the drain is event-driven).

Errors: 400 if channel_id or content is empty. 501 if message sending is not configured and no handler is set.


DELETE /api/messages/{id}

Remove a waiting user message from a channel’s queue before the orchestrator dispatches it.

Path Parameters:

ParamTypeDescription
idstringmsg_id of the message to delete (platform-specific message ID)

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel owning the message (msg_id is unique per channel, not globally)

Response: 204 No Content

Behavior notes:

  • Only deletes rows where is_bot = 0 AND is_processed = 0 AND is_running = 0 — bot replies, already-processed history and a message an agent has already claimed can never be removed through this endpoint.
  • On success, broadcasts a message.deleted WebSocket event so connected clients remove the message from their local state.
  • A queued row deleted while another run is in progress simply never gets claimed: ClaimNextPending only sees is_processed=0 AND is_triggered=1 AND is_running=0 rows, so the atomic claim transaction can never hand the deleted row to an agent.

Errors: 400 if channel_id is missing. 404 if no matching deletable row exists (message missing, already processed or running, or is a bot message). 500 on database error. 501 if message deletion is not configured.


GET /api/channels/{id}/sessions

List Claude Code session JSONL files for a channel’s project directory.

Path Parameters:

ParamTypeDescription
idstringChannel ID

Response:

{
  "current_session_id": "4482da1c-831c-...",
  "sessions": [
    {
      "session_id": "4482da1c-831c-...",
      "last_modified": "2026-03-25T14:30:00Z",
      "last_message": "I've updated the configuration file..."
    }
  ],
  "imported_session_ids": ["4482da1c-831c-..."]
}

Sessions are sorted by modification time (newest first). last_message is extracted from the last assistant or user message in the JSONL file (last 32KB reverse-scanned). current_session_id is the session currently associated with the channel. imported_session_ids lists the session ids already held by a channel or thread.

Learn passes’ sessions aren’t importable and are left out: every pass forks a new session file into the channel’s project dir. A file is taken for one when its id is a learn thread’s own session (not the reviewed run’s, which a learn thread holds with fork_pending until its pass forks it), or when the first prompt it logs (the first queue-operation enqueue in its first 64KB, which Claude writes at the top of a --print session, before the history a fork copies in) is a learn pass’s trigger message. Explanations’ sessions are left out the same way.


PUT /api/channels/{id}/session

Switch the channel to another of its project’s Claude sessions, so its next run resumes that conversation. The Sessions panel’s resume here button and the resume_session MCP tool call it.

Request:

{ "session_id": "4482da1c-831c-..." }
  • The session must be a transcript in the channel’s project dir, as listed by GET /api/channels/{id}/sessions .
  • The chat’s messages are left as they are.
  • A session another channel or thread also holds is stored with fork_pending, so the next run starts with --fork-session instead of writing into the other’s conversation.
  • While a chat run is in progress in the channel, the switch is held (in memory) and applied when the run ends, after the run saves its own session id.

Response:

{ "deferred": false }

deferred is true when the switch waits for the run in progress.

Errors: 400 for a missing or malformed session_id, or a channel with no project dir. 404 if the channel or the session isn’t found. 500 on database error. 501 if session switching is not configured.


GET /api/channels/{id}/agent-config

Return the channel’s per-channel model/effort overrides plus the effective config defaults they fall back to (global → project → worktree merge for the channel’s dir), so a UI can label “default” concretely.

Response (200):

{
  "model": "claude-opus-4-8",
  "effort": "high",
  "default_model": "claude-sonnet-5-5",
  "default_effort": "",
  "models": ["claude-opus-5-5", "claude-opus-5-5[1m]", "claude-opus-5", "..."]
}

model / effort are empty when the channel inherits from config. models lists the model ids to offer — the same list as the claude_model config options; a [1m] id runs the model with its 1M-token context window.

Errors: 404 if the channel doesn’t exist. 501 if the store is not configured.


PATCH /api/channels/{id}/agent-config

Set the channel’s model/effort overrides. Empty strings clear an override (inherit from config). Takes effect on the channel’s next agent run — chat and scheduled runs alike — with no restart.

Request:

{"model": "claude-opus-4-8", "effort": "high"}

effort must be one of low, medium, high, xhigh, max, or empty. model is free text (any Claude model id).

Response: 204 No Content.

Behavior notes: Broadcasts a channel.agent_config event with the new overrides so the sidebar updates live.

Errors: 400 on invalid effort. 404 if the channel doesn’t exist. 501 if the store is not configured.


GET /api/channels/{id}/container-stats

Live CPU/memory usage for the channel’s running containers, digested from the Docker stats endpoint with the docker-cli formulas (CPU% from cpu/system deltas × online CPUs; memory usage minus reclaimable page cache). The desktop app polls this every ~3s to render the readout in the chat and docker-agent pane headers.

Response (200):

[
  {
    "container_id": "0a1b2c…",
    "type": "agent",
    "cpu_percent": 12.5,
    "mem_usage": 402653184,
    "mem_limit": 4294967296
  }
]
FieldTypeDescription
typestringRegistry container type: agent (chat runs), shell (terminal panes), chrome, …
cpu_percentnumberPercent of one CPU; can exceed 100 on multi-core containers
mem_usage / mem_limitnumberBytes; usage excludes reclaimable page cache

Behavior notes:

  • Containers whose stats fetch fails (teardown race) are silently skipped.
  • Returns [] when the channel has no running containers or the daemon lacks a stats source — never an error.
  • The non-streaming Docker stats call takes ~1s server-side (the daemon primes the CPU delta), so responses are not instant.

GET /api/channels/{id}/audit

List the agent-gate audit files accumulated for a channel. The files are rotating JSONL (agentgate-YYYY-MM-DD.jsonl) written by FileAuditor inside the container and kept on the host under {policyDir}/<channel>/audit/. See Security Gate: Known gaps for the record schema and the verbose flag.

Path Parameters:

ParamTypeDescription
idstringChannel ID

Query Parameters:

ParamTypeDefaultMaxDescription
offsetint0–Skip the first N files (files are returned newest-first by date)
limitint50500Number of files to return

Response (200):

{
  "files": [
    {
      "date": "2026-04-24",
      "size": 12456,
      "last_modified": "2026-04-24T18:02:11Z"
    }
  ],
  "total": 1
}

Behavior notes:

  • date is parsed out of the filename and validated against YYYY-MM-DD; anything else is skipped.
  • Files are sorted newest-first by date for infinite-scroll pagination.
  • When the audit directory does not yet exist (container never spawned or gate disabled), the endpoint returns {"files": []} with 200 rather than 404.

Errors: 501 if the audit-dir resolver is not configured.


DELETE /api/channels/{id}/audit/{date}

Remove one audit file from disk.

Path Parameters:

ParamTypeDescription
idstringChannel ID
datestringYYYY-MM-DD

Response: 204 No Content on success.

Errors: 400 if date is not YYYY-MM-DD. 500 if the unlink fails. 501 if the audit-dir resolver is not configured.


GET /api/channels/{id}/messages

List messages for a channel. Supports two modes: cursor-based pagination (default) and around mode.

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Query Parameters (cursor mode):

ParamTypeDefaultMaxDescription
cursorint640–Fetch messages older than this message ID
limitint50200Number of messages to return

Query Parameters (around mode):

ParamTypeDescription
aroundint64Center message ID; returns messages surrounding it
limitintTotal messages to return (split evenly before/after)

Response (200):

{
  "messages": [
    {
      "id": 42,
      "channel_id": "abc123",
      "msg_id": "discord_msg_id",
      "author_id": "user123",
      "author_name": "Alice",
      "content": "Hello!",
      "is_bot": false,
      "trigger_msg_id": "",
      "created_at": "2026-01-01T00:00:00Z"
    }
  ],
  "next_cursor": 41
}

trigger_msg_id is the msg_id of the user message whose agent run produced this row. Empty (and omitted in JSON via omitempty) for user messages and pre-feature bot rows.

forkable is true on a message that records its place in the session’s transcript (an agent reply, a user message whose run has ended, or a message a fork or resume imported), so the conversation can be forked at it . It is omitted otherwise.

Behavior notes:

  • Cursor mode: Fetches limit+1 messages to determine if more exist. If so, next_cursor is set to the last returned message’s ID. Messages are ordered oldest-first.
  • Around mode: Uses a UNION ALL query (half before + half after the target message ID), ordered by id ASC. next_cursor is not set in around mode.
  • This endpoint returns chat-only rows (kind = 'message'); agent thinking and tool events are not included. Use /api/channels/{id}/timeline for an interleaved view.

Errors: 400 if query params are invalid. 501 if message listing is not configured.


GET /api/channels/{id}/queued

Return the canonical queue of unprocessed user messages for a channel — every row with kind = 'message', is_bot = 0, is_processed = 0, ordered by priority DESC, id ASC (the exact order the orchestrator drains in). The chat UI calls this on channel mount and after every event that could change the queue (message.created, message.deleted, message.updated, messages.processed, agent.status) so the “queued”/“processing” labels and the queued-messages popup stay correct even when older pages of chat history are not loaded in the renderer.

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Response (200):

{
  "messages": [
    {
      "id": 51,
      "channel_id": "abc123",
      "msg_id": "msg-bumped",
      "author_id": "user123",
      "author_name": "Alice",
      "content": "do this first instead",
      "is_bot": false,
      "is_processed": false,
      "priority": 1,
      "created_at": "2026-01-01T00:00:30Z"
    },
    {
      "id": 49,
      "channel_id": "abc123",
      "msg_id": "msg-older",
      "author_id": "user123",
      "author_name": "Alice",
      "content": "do this second",
      "is_bot": false,
      "is_processed": false,
      "created_at": "2026-01-01T00:00:00Z"
    }
  ]
}

The in-flight message is included in the response, marked "is_running": true (omitted on the others). Clients use the agent.status event’s msg_id to distinguish “processing” from “queued”, and is_running until that event arrives. A scheduled task running in the thread claims no row, so every message sent meanwhile stays “queued” until it finishes. Higher priority values sort first; priority is omitted when zero. Rows queued with a delay carry not_before (unix seconds); the chat UI renders a live countdown chip until it elapses. not_before is omitted when zero (immediate). A row being edited in some window carries edit_hold_until (unix seconds) until the edit is saved, cancelled, or its lease lapses; it and everything behind it won’t start meanwhile (see POST /api/channels/{id}/queued/{msg_id}/hold ).

Errors: 501 if message listing is not configured. 500 on database error.


POST /api/channels/{id}/queued/{msg_id}/steer

Promote one queued message to the front of the channel’s queue and cancel the active run, so the agent takes that message next instead of when the current turn ends. This is the per-row form of POST /api/messages with interrupt=true, and makes the same trade: the cancelled run’s session is resumed on the next turn, so the work so far is redirected rather than discarded. Queued rows are preserved.

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID
msg_idstringmsg_id of the queued row to steer

Request Body: none.

Response: 204 No Content.

Ordering matters and is handled server-side: the row is promoted before the run is cancelled, or the dying run’s drain loop could claim an older queued row in the window between the two. A row already claimed by a run (is_running = 1) is never promoted — the response is 404 and the active run is left alone. A row that was queued with a delay has its not_before pulled back to 1: already in the past, so ClaimNextPending accepts it, but still non-zero so the delay poller keeps seeing it and wakes an otherwise idle channel.

Errors: 404 if no matching queued row exists (already processed, already running, wrong channel, or never existed). 501 if the store is not configured. 500 on database error.


POST /api/channels/{id}/queued/{msg_id}/hold

Hold a queued user message so it can be edited: until the hold expires or is released, the drain won’t start it — nor anything queued behind it, so queue order is kept (see Edit holds ). Calling it again renews the hold; the chat app renews every 2 minutes while the edit is open.

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID
msg_idstringmsg_id of the queued row

Request Body: none.

Response (200):

{ "hold_until": 1790000000 }

hold_until is unix seconds, 5 minutes from now.

Errors: 409 if the row is no longer waiting — it already started, finished, was deleted, or never existed (bot rows and non-message rows never match). 501 if the store is not configured. 500 on database error.


DELETE /api/channels/{id}/queued/{msg_id}/hold

Release an edit hold without changing the message — the cancel path. When a hold was actually cleared, the channel’s drain is kicked so the row (and anything behind it) can start.

Response: 204 No Content, also when there was nothing to release.

Errors: 501 if the store is not configured. 500 on database error.


PUT /api/channels/{id}/queued/{msg_id}

Replace a queued user message’s content and clear its edit hold in one write, then kick the channel’s drain. Broadcasts message.updated so other windows show the new text.

Request Body:

{ "content": "the edited prompt" }

Response: 204 No Content.

The write only matches a row that is still waiting (is_running = 0 AND is_processed = 0), and it serializes with the orchestrator’s claim on SQLite’s single writer. If a run claimed the row first — typically after the hold lapsed — the response is 409 and the edit is not applied; the run uses the original text.

Errors: 400 on an invalid body or blank content. 409 if the row is no longer waiting. 501 if the store is not configured. 500 on database error.


POST /api/channels/{id}/monitors

Start a monitor: a watch script that runs in the background, so the agent’s run can end while it waits. This is what the monitor MCP tool calls. The script runs with /bin/sh -c in a fresh agent container for the channel, like a scheduled bash task, with the channel’s dir and mounts, and the request returns at once. When the script exits or times out, a message is queued in the channel, as queue_message would, which starts the agent’s next turn:

[monitor mon-1a2b3c4d] "PR 7 merged" exited with status 0. Output:

followed by the last 4000 bytes of the script’s output in a code block (or No output.). The status reads exited with status N, timed out after 1h0m0s, or the error that stopped the container.

Request Body:

{ "command": "until gh pr view 7 --json state -q .state | grep -q MERGED; do sleep 60; done", "description": "PR 7 merged", "timeout_sec": 3600, "interrupt": false }
FieldTypeRequiredDescription
commandstringyesThe script
descriptionstringyesWhat it waits for; shown in the chat and the wake-up message
timeout_secintnoStop the script after this long. 0 means one hour; at most 24 hours
interruptboolnoSend the wake-up message as an interrupt, like POST /api/messages with interrupt: it jumps the queue and cancels the agent’s active run. Default false: it queues behind that run

Response (201): the monitor, shaped as in GET /api/channels/{id}/monitors .

Behavior notes: Broadcasts monitor.started now and monitor.finished when the script ends. Monitors live in the daemon’s memory: a cancelled one, and every one still running when the daemon stops, ends without a wake-up message. A container approval for the script’s container shows in the channel’s chat like any other.

Errors: 400 on an invalid body, a blank command or description, a negative timeout_sec, or a channel that doesn’t exist. 409 if the channel already runs 10 monitors. 503 while the daemon shuts down. 501 if monitors are not configured.


GET /api/channels/{id}/monitors

List the channel’s running monitors, oldest first.

Response (200):

{
  "monitors": [
    { "id": "mon-1a2b3c4d", "channel_id": "ch1", "description": "PR 7 merged", "command": "until …; do sleep 60; done", "started_at": "2026-01-02T03:04:05Z", "timeout_sec": 3600, "interrupt": false }
  ]
}

Errors: 501 if monitors are not configured.


DELETE /api/channels/{id}/monitors/{monitor_id}

Stop a monitor’s container without waking the agent. The chat’s monitor row has a Cancel button for this.

Response: 204 No Content.

Errors: 404 if the channel has no such running monitor. 501 if monitors are not configured.


GET /api/channels/{id}/live

Owner only. The channel’s live session , and whether its chat turns run live.

Response (200):

{ "running": true, "enabled": true, "channel_id": "ch1", "started_at": "2026-01-02T03:04:05Z", "session_id": "0a1b2c3d-…", "busy": false, "background": "1 background task: watch CI", "background_tasks": [{ "id": "b1x2", "type": "local_bash", "description": "watch CI", "started_at": "2026-01-02T03:04:06Z" }] }

Fields are as in the live.status event, plus enabled: whether the channel’s plain chat turns run live (live_sessions and live_session_platforms allow its platform, and it isn’t a hidden learn or explain thread). With no session running, it is {"running": false, "busy": false} plus enabled.

Errors: 501 if live sessions are not configured.


POST /api/channels/{id}/live

Owner only. Start the channel’s live session ahead of its next chat turn, as that turn would: resuming the channel’s session, or forking the parent’s for a thread’s first turn. The chat’s composer calls it when you start typing, so the container is up by the time the message is sent. It does nothing when a session already runs, a run or scheduled task holds the channel, or the channel’s turns don’t run live.

Response (200):

{ "started": true }

started is whether this call started the session; when it did, a live.status event follows.

Errors: 500 if the session failed to start. 503 if no live runner is set up. 501 if live sessions are not configured.


DELETE /api/channels/{id}/live

Owner only. Stop the channel’s live session, with reason stopped. Its Claude session stays: the next message starts a new live session on it.

Response: 204 No Content.

Errors: 404 if the channel has no live session. 501 if live sessions are not configured.


POST /api/channels/{id}/live/tasks/{task_id}/stop

Owner only. Stop one background task of the channel’s live session: a shell command, a Monitor, a subagent or a workflow, by the id its background_tasks carry. Claude stops it and reports the tasks left, which ends its pill above the composer. The session and its turn, if one runs, go on.

Response: 204 No Content.

Errors: 404 if the channel has no live session, or the session runs no background task with that id. 503 if no live runner is set up. 501 if live sessions are not configured.


POST /api/channels/{id}/side-chats

Owner only. Create a side chat of the channel or thread: an unlisted local thread with its own live session, for a Chat (Resume with fork) or Chat (Fresh session) pane. It is left out of the channel list, search and plugin events, and is deleted with its parent.

Request Body:

{ "mode": "fork" }
FieldTypeRequiredDescription
modestringyesfork: its first turn forks the channel’s current session. fresh: it starts a new session in the channel’s project

Response (201):

{ "channel_id": "side-1a2b3c4d5e6f" }

Errors: 400 on an invalid body or mode. 404 if the channel doesn’t exist. 409 if the channel is itself unlisted (a side chat, or a learn or explain thread), or in fork mode when it has no session yet. 501 if the store is not configured.


GET /api/channels/{id}/side-chats

Owner only. List the channel’s side chats, as channel rows ([] when there are none). The app uses it to delete side chats whose pane is gone.

Errors: 501 if the store is not configured.


DELETE /api/side-chats/{id}

Owner only. Stop a side chat’s live session (reason closed) and delete it like a thread, with its messages and containers. Its Claude session’s transcript stays.

Response: 204 No Content.

Errors: 404 if there’s no side chat with that id. 501 if the store or threads are not configured.


GET /api/channels/{id}/timeline

List the channel’s interleaved timeline — chat messages plus persisted agent events (thinking blocks, tool calls, tool results) — in canonical chain order. Each row carries a kind discriminator and a per-channel chain_position.

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Query Parameters:

ParamTypeDefaultMaxDescription
cursor_positionint640–Fetch rows older than this chain_position. Pair with cursor_id for the second-key tiebreaker. 0 is a valid sentinel that pages into legacy rows.
cursor_idint640–Tiebreaker id for rows that share cursor_position (in particular legacy rows where chain_position = 0).
limitint50200Number of rows to return

Response (200):

{
  "items": [
    {
      "kind": "message",
      "position": 12,
      "id": 101,
      "data": {
        "id": 101,
        "channel_id": "abc123",
        "msg_id": "user_msg_uuid",
        "author_id": "user123",
        "author_name": "Alice",
        "content": "Refactor the auth middleware",
        "is_bot": false,
        "is_processed": true,
        "created_at": "2026-04-30T08:00:00Z"
      }
    },
    {
      "kind": "thinking",
      "position": 13,
      "id": 102,
      "text": "Let me check how the existing tests cover this path...",
      "truncated": false,
      "trigger_msg_id": "user_msg_uuid"
    },
    {
      "kind": "tool_use",
      "position": 14,
      "id": 103,
      "tool_use_id": "toolu_017fNc...",
      "tool_name": "Read",
      "tool_input": "{\"file_path\":\"/work/internal/api/auth.go\"}",
      "trigger_msg_id": "user_msg_uuid"
    },
    {
      "kind": "tool_result",
      "position": 15,
      "id": 104,
      "tool_use_id": "toolu_017fNc...",
      "text": "package api\n\n// ...\n",
      "is_error": false,
      "truncated": true,
      "trigger_msg_id": "user_msg_uuid"
    }
  ],
  "next_cursor": { "position": 12, "id": 101 }
}
FieldTypeDescription
items[].kindstringOne of "message", "thinking", "tool_use", "tool_result"
items[].positionint64Per-channel monotonic chain position. 0 for legacy rows that pre-date the timeline feature.
items[].idint64Row id; used as a stable tiebreaker for cursor pagination
items[].dataobjectPresent when kind == "message"; same shape as /messages rows (id, channel_id, msg_id, author_id, author_name, content, is_bot, is_processed, trigger_msg_id, created_at)
items[].textstringPresent when kind ∈ {"thinking", "tool_result"}. Capped at 8 KiB inline; the full content was truncated server-side at the same cap when the run wrote it.
items[].truncatedbooltrue when the row’s content was truncated to fit the inline cap
items[].tool_use_idstringPairs tool_use rows with their matching tool_result row (and matching live tool.use / tool.result events)
items[].tool_namestringPresent on tool_use rows
items[].tool_inputstringPresent on tool_use rows; serialised tool input (truncated to 8 KiB inline)
items[].is_errorboolPresent on tool_result rows; true when the tool failed
items[].trigger_msg_idstringThe msg_id of the user message whose agent run produced this row. Present on bot replies and agent events (thinking, tool_use, tool_result, compacting). Empty/omitted on user messages and pre-feature rows. The FE uses it to group events under their triggering message, surviving out-of-order processing of priority-bumped messages.
next_cursorobject|null{position, id} to fetch the next page; null when there are no more rows

Behavior notes:

  • Rows are ordered by (chain_position DESC, id DESC) so the response runs newest → oldest.
  • The endpoint fetches limit + 1 rows to determine whether a next page exists. If so, next_cursor is set to the (chain_position, id) of the row past the cap.
  • Legacy rows (chat from before this feature shipped) all carry chain_position = 0 and are ordered by id within that bucket. Cursor pagination handles the boundary between backfilled rows (chain_position > 0) and legacy rows transparently.
  • Thinking, tool-use, and tool-result content are stored inline on the message row and truncated at 8 KiB when the docker stream writes them. The truncated flag tells the UI when a block was clipped.
  • Live agent.thinking / tool.use / tool.result SSE events fire alongside row inserts, so the desktop app can render them in real time and refetch the head of the timeline on run completion.

Errors: 400 if cursor params are negative or non-numeric. 501 if the timeline service is not configured.


GET /api/messages/search

Full-text search across all messages using case-insensitive LIKE %query%. Messages in hidden learn and explain threads are left out.

Query Parameters:

ParamTypeDefaultMaxRequiredDescription
qstring––yesSearch query
limitint2050noMax results

Response (200):

[
  {
    "id": 42,
    "channel_id": "abc123",
    "author_name": "Alice",
    "content": "matching message",
    "is_bot": false,
    "created_at": "2026-01-01T00:00:00Z"
  }
]

Errors: 400 if q is empty. 501 if message search is not configured.


GET /api/channels/{id}/messages/search

Finds the channel’s messages whose content contains q, case-insensitively. % and _ in q match literally. Tool calls, tool results and thinking rows are not searched. Backs the chat’s find bar.

Query Parameters:

ParamTypeDefaultMaxRequiredDescription
qstring––yesText to find (trimmed)
limitint5001000noMax results

Response (200): the matching message ids, newest first.

{ "ids": [812, 640, 57] }

Errors: 400 if q is empty or limit is invalid. 501 if message search is not configured.


Learn

A channel’s learn switch, its hidden learn thread, and the proposals learn passes file. See Chat: Learn from a run . The channel endpoints below return 404 when {id} is itself a learn thread.

GET /api/channels/{id}/learn

Return the channel’s learn switch, the config default it falls back to (global → project → worktree merge for the channel’s dir), and its learn thread.

Response (200):

{
  "available": true,
  "learn": "on",
  "default_learn": false,
  "enabled": true,
  "learn_channel_id": "learn-a1b2c3d4e5f6",
  "running": false
}
FieldTypeDescription
availableboolfalse for Slack and Discord channels and scheduled tasks’ threads, which never learn
learnstringThe channel’s setting: "on", "off", or empty to inherit default_learn
default_learnboollearn.enabled from the merged config; false when the config fails to load
enabledboolThe effective switch; always false when not available
learn_channel_idstringThe hidden learn thread; empty until the channel’s first learn pass
runningboolA learn pass is running in the learn thread. A pass still waiting for its turn, or your own reply running there, doesn’t count.

Errors: 404 if the channel doesn’t exist or is a learn thread. 500 on a store error. 501 if the store is not configured.


PUT /api/channels/{id}/learn

Set the channel’s learn switch. It takes effect from the channel’s next run.

Request:

{"learn": "off"}

learn must be "on", "off", or empty to inherit the config.

Response: 204 No Content.

Behavior notes: Broadcasts a channel.learn event.

Errors: 400 if the body isn’t valid JSON, learn is another value, or the channel is a Slack or Discord one (learn runs only in desktop app channels) or a scheduled task’s thread (learn doesn't run in task threads). 404 if the channel doesn’t exist or is a learn thread. 501 if the store is not configured.


GET /api/channels/{id}/learn/proposals

List the proposals filed for the channel, newest first, in every status.

Response (200):

{
  "proposals": [
    {
      "id": 12,
      "channel_id": "abc123",
      "learn_channel_id": "learn-a1b2c3d4e5f6",
      "message_id": "loop-msg-17",
      "kind": "bash_shortcut",
      "title": "Add a make lint bash shortcut",
      "rationale": "make lint was typed three times in this run.",
      "payload": "{\"name\":\"lint\",\"description\":\"Run the linter\",\"command\":\"make lint\"}",
      "status": "pending",
      "created_at": "2026-03-25T14:30:00Z",
      "updated_at": "2026-03-25T14:30:00Z"
    }
  ]
}
FieldTypeDescription
message_idstringThe turn (its last bot message’s msg_id) whose learn pass filed it; omitted when unknown
kindstringprompt_shortcut, bash_shortcut, scheduled_task, gate_rule, mount, rename, description or ticket_url
payloadstringThe kind’s payload as a JSON string (see POST )
statusstringpending, applying, applied, dismissed, failed or withdrawn
errorstringWhy the last apply failed; omitted when empty
withdrawn_reasonstringWhy a later learn pass withdrew it; set only when status is withdrawn

proposals is [] when there are none.

Errors: 404 if the channel doesn’t exist or is a learn thread. 500 on a store error. 501 if the store is not configured.


POST /api/channels/{id}/learn/proposals

File a learn pass’s proposals and withdraw earlier ones it found stale. This is what the learn agent’s propose_learnings MCP tool calls. Here {id} is the learn thread; the proposals are stored for the channel it learns from, as pending.

Request:

{
  "proposals": [
    {
      "kind": "gate_rule",
      "title": "Ask before git push",
      "rationale": "The agent pushed without asking.",
      "payload": {"type": "command", "rule": {"commands": ["git"], "args_patterns": ["^push( .*)?$"], "decision": "approve", "message": "git push"}},
      "replaces": 9
    }
  ],
  "withdraw": [
    {"id": 7, "reason": "The run removed the lint target this shortcut calls."}
  ]
}
FieldTypeDescription
proposalsarrayNew proposals, at most 5
proposals[].replacesintOptional id of an earlier proposal this one supersedes; it’s withdrawn with the reason replaced by a newer proposal
withdrawarrayEarlier proposals to withdraw, at most 20, each {id, reason}; reason is required, at most 300 characters, trimmed

At least one of proposals and withdraw must be non-empty: a call may only withdraw. Only the channel’s own pending or failed proposals can be withdrawn, never one applying, applied, dismissed or already withdrawn. A withdrawn proposal keeps its row, with status: "withdrawn" and the reason in withdrawn_reason; a failed one’s error is cleared.

At most 5 proposals per call. Each needs a kind, a title (at most 200 characters) and a payload object; rationale is optional (at most 1000 characters). Title and rationale are trimmed. Payloads by kind (unknown fields are rejected):

KindPayload
prompt_shortcut{"name", "description"?, "prompt"}. name at most 100 characters.
bash_shortcut{"name", "description"?, "command"}. name at most 100 characters.
scheduled_task{"type", "schedule", "prompt"?, "bash_script"?, "auto_delete_sec"?}. type is cron, interval or once; exactly one of prompt / bash_script; auto_delete_sec not negative.
gate_rule{"type", "rule"}. type is path, command or file; rule is an agentgate rule of that list, and must compile and name what it matches: a command rule needs commands or args_patterns, a file rule paths, a path rule pattern. A rule without one would match every call and, as project rules match first, override the global rules.
mount{"mount"}, host_path:container_path[:ro|rw].
rename{"name"}, at most 100 characters.
description{"description"}, at most 500 characters.
ticket_url{"ticket_url"}, an absolute http(s) URL, at most 2048 characters.

The payload is stored in canonical JSON. New proposals get the message_id of the learn thread’s running pass, else its newest done one (a user’s reply in the learn thread files against the turn that pass reviewed); none when it has neither. The withdrawals and the inserts run in one transaction: either every proposal is valid and every withdrawal allowed, and all are stored, or nothing changes. A withdrawal takes the proposal the same way apply claims it (a conditional update on its status), so one the user is applying or dismissing at that moment can’t be withdrawn, and one withdrawn first can’t be applied.

Response (201): {"proposals": [...], "withdrawn": [...]}, the stored proposals and the withdrawn ones as in the GET above. proposals is [] for a withdraw-only call; withdrawn is omitted when none were.

Behavior notes: Broadcasts a learn.proposals event for the learning channel.

Errors: 400 if the body isn’t valid JSON, both proposals and withdraw are empty (proposals or withdraw is required), proposals has more than 5 items or withdraw more than 20, a proposal is invalid (the message names it, e.g. proposal 2: title is required), a withdrawal has no reason (withdraw 1: reason is required), or a proposal is withdrawn twice in the call (withdraw 2: proposal 7 is withdrawn twice, also when a replaces repeats one). 404 if {id} isn’t a learn thread. 409 if a proposal to withdraw isn’t the channel’s (proposal 7 not found in this channel) or isn’t open (proposal 7 is applied; only pending or failed proposals can be withdrawn). 500 on a store error. 501 if the store is not configured.


GET /api/channels/{id}/learn/passes

List the channel’s learn passes, newest first, in every status. Each reviews one turn, the one ending with bot message message_id.

Response (200):

{
  "passes": [
    {
      "id": 5,
      "channel_id": "abc123",
      "message_id": "loop-msg-17",
      "learn_channel_id": "learn-a1b2c3d4e5f6",
      "status": "done",
      "created_at": "2026-03-25T14:30:00Z",
      "updated_at": "2026-03-25T14:31:10Z",
      "message_row_id": 412
    }
  ]
}
FieldTypeDescription
message_idstringThe reviewed turn’s last bot message (msg_id)
learn_channel_idstringThe channel’s hidden learn thread, where the pass runs
statusstringqueued, running, done or failed. Passes from before passes queued one after another may also be superseded: a newer pass replaced it before it started.
errorstringWhy the pass failed; omitted when empty
message_row_idintThe bot message’s row id, for linking to it; omitted when the message is gone

passes is [] when there are none. A pass is recorded only when its turn has a bot reply. Passes left queued or running when Loop stopped are marked failed at the next start, unless their trigger still waits to run.

Behavior notes: Each status change is broadcast as a learn.pass event.

Errors: 404 if the channel doesn’t exist or is a learn thread. 500 on a store error. 501 if the store is not configured.


POST /api/channels/{id}/learn/passes

Start a learn pass over the turn that ended with bot message message_id, on demand. It runs whether or not the channel’s Learn switch is on, and whatever the turn’s length (learn.min_turns). A pass over the turn already queued or running is returned as it is; otherwise a new one is queued, also when the turn’s earlier pass is done or failed (or superseded, on an old row).

Request:

{"message_id": "loop-msg-17"}

Response (200): the pass, shaped as in the list (without message_row_id).

Behavior notes: A new pass creates the channel’s hidden learn thread on first use and is broadcast as a learn.pass event, as is each later status change. It forks the session where the turn ended, so it sees the turn as it was even when later ones followed (a turn from before Loop recorded where turns end forks the channel’s current session instead), and its trigger message quotes the turn’s prompt and final reply. Like an automatic pass, it queues behind the passes already in the learn thread and runs after them; nothing is replaced.

Errors: 400 if the body isn’t valid JSON, message_id is missing, the channel isn’t one learn passes run for (a Slack or Discord channel, or a task thread), or the message isn’t a bot message of a turn in the channel. 404 if the channel doesn’t exist, is hidden, or was deleted meanwhile. 409 if neither the turn nor the channel has a session to fork. 500 on a store error. 501 if on-demand learn passes are not configured.


POST /api/learn/proposals/{id}/apply

Apply a pending or failed proposal. It moves to applying first, so a double click can’t apply it twice. One left in applying for over a minute (its outcome was lost: the status save failed, or Loop stopped mid-apply) can be applied or dismissed again. See Configuration: Where learn proposals are written for what each kind changes.

Response (200): the proposal, with status applied, or failed and an error. A failed apply is recorded on the proposal, not returned as an HTTP error, so it can be retried.

Behavior notes: Broadcasts a learn.proposal_updated event. A rename, description or ticket URL also broadcasts channel.updated, a scheduled task task.created. Config-kind applies to the same .loop/config.json (e.g. several proposals applied at once) are serialized, from the duplicate check to the write, so none is lost or added twice.

Errors: 400 if {id} isn’t an integer. 404 if the proposal doesn’t exist. 409 if it’s already applied, dismissed or withdrawn (a learn pass may withdraw it while the request is in flight), or has been applying for under a minute (proposal is already applied). 500 on a store error. 501 if the store is not configured.


GET /api/learn/proposals/{id}/preview

What applying a proposal would change in the project config, without changing it. The server works out the same edit apply makes, with the same duplicate checks, against the file as it is now. Owner-only.

Response (200):

{
  "path": "/home/user/project/.loop/config.json",
  "diff": "--- /home/user/project/.loop/config.json\n+++ /home/user/project/.loop/config.json\n@@ -1,3 +1,6 @@\n {\n-  \"envs\": {}\n+  \"envs\": {},\n+  \"mounts\": [\n+    \"~/.aws:~/.aws:ro\"\n+  ]\n }\n"
}
FieldDescription
pathThe .loop/config.json the proposal edits (see Where learn proposals are written )
diffThe edit as a unified diff, from /dev/null when the file would be created. Omitted when the change is already there (a gate rule the config has)
errorWhy applying it would fail (a shortcut or mount that already exists, a config that doesn’t parse), as apply would record it

All fields are omitted for kinds that edit no file (scheduled_task, rename, description, ticket_url).

Errors: 400 if {id} isn’t an integer. 404 if the proposal doesn’t exist. 500 on a store error. 501 if the store is not configured.


POST /api/learn/proposals/{id}/dismiss

Dismiss a pending or failed proposal, or one stuck in applying as above.

Response (200): the proposal, with status: "dismissed".

Behavior notes: Broadcasts a learn.proposal_updated event.

Errors: Same as apply.


Explain

A channel’s explain switch and the explanations of its turns. See Chat: Explain a turn . These endpoints return 404 when {id} is a hidden learn or explain thread, and 501 if the store is not configured.

GET /api/channels/{id}/explain

Return the channel’s explain switch and the config default it falls back to (global → project → worktree merge for the channel’s dir).

Response (200):

{"available": true, "explain": "", "default_explain": false, "enabled": false}
FieldTypeDescription
availableboolfalse for Slack and Discord channels and scheduled tasks’ threads, which are never explained
explainstringThe channel’s setting: "on", "off", or empty to inherit default_explain
default_explainboolexplain.enabled from the merged config; false when the config fails to load
enabledboolThe effective switch; always false when not available

Errors: 404 if the channel doesn’t exist or is hidden. 500 on a store error.


PUT /api/channels/{id}/explain

Set the channel’s explain switch: with it on, every turn that completes is explained. It takes effect from the channel’s next run.

Request:

{"explain": "on"}

explain must be "on", "off", or empty to inherit the config.

Response: 204 No Content.

Behavior notes: Broadcasts a channel.explain event.

Errors: 400 if the body isn’t valid JSON, explain is another value, or the channel is never explained (explain runs only in desktop app channels, explain doesn't run in task threads). 404 if the channel doesn’t exist or is hidden. 500 on a store error.


GET /api/channels/{id}/explanations

List the channel’s explanations, newest first, in every status.

Response (200):

[
  {
    "id": 3,
    "channel_id": "abc123",
    "message_id": "loop-msg-17",
    "explain_channel_id": "explain-a1b2c3d4e5f6",
    "status": "done",
    "content": "### Summary\n\nAdded a lint target...",
    "created_at": "2026-03-25T14:30:00Z",
    "updated_at": "2026-03-25T14:31:10Z",
    "message_row_id": 412,
    "prompt": "add a lint target",
    "reply": "Added `make lint`..."
  }
]
FieldTypeDescription
message_idstringThe explained turn’s last bot message (msg_id)
explain_channel_idstringThe channel’s hidden explain thread, where the run goes
statusstringqueued, running, done or failed
contentstringThe explanation in markdown; empty until done
errorstringWhy the run failed; omitted when empty
message_row_idintThe bot message’s row id, for linking to it; omitted when the message is gone
prompt / replystringThe first 300 characters of the turn’s prompt and final reply; omitted when gone

The list is [] when there are none.

Errors: 404 if the channel doesn’t exist or is hidden. 500 on a store error.


POST /api/channels/{id}/explanations

Explain the turn that ended with bot message message_id. Without force, an existing explanation is returned as it is; with force (Re-explain) a done or failed one is queued again, replacing its content. An explanation already queued or running is returned as it is either way, unless it has been so for over an hour (its run was lost).

Request:

{"message_id": "loop-msg-17", "force": false}

Response (200): the explanation, shaped as in the list.

Behavior notes: A newly queued explanation creates the channel’s hidden explain thread on first use, queues a run there that forks the channel’s current session, and broadcasts an explain.updated event, as does each later status change.

Errors: 400 if the body isn’t valid JSON, message_id is missing, the channel is never explained, or the message isn’t a bot message of a turn in the channel. 404 if the channel doesn’t exist, is hidden, or was deleted meanwhile. 409 if the channel has no session to fork yet. 500 on a store error. 501 if explanations are not configured.


Tasks

POST /api/tasks

Create a new scheduled task.

Request:

{
  "channel_id": "abc123",
  "schedule": "0 9 * * *",
  "type": "cron",
  "prompt": "Summarize today's PRs",
  "template_name": "daily-summary",
  "auto_delete_sec": 3600,
  "worktree": true,
  "origin_branch": "main",
  "update_before_run": true
}
FieldTypeRequiredDescription
channel_idstringyesChannel to run the task in
schedulestringyesCron expression, Go duration, or RFC3339 timestamp
typestringyescron, interval, or once
promptstringnoPrompt text for the agent (required unless workflow_name or bash_script is set; a task with none of them is a 400)
template_namestringnoTemplate identifier for deduplication
auto_delete_secintnoAuto-delete thread after N seconds
worktreeboolnoRun the agent in an isolated git worktree
origin_branchstringnoBase branch for worktree tasks. Auto-detected on first run if omitted.
update_before_runboolnoPrepend git fetch/rebase instructions to the prompt before each run
workflow_namestringnoName of a workflow to run on schedule (mutually exclusive with prompt)
workflow_inputsstringnoJSON object of inputs to pass to the workflow
bash_scriptstringnoShell script to run in the channel’s agent container on schedule (mutually exclusive with prompt and workflow_name); output is posted to the channel

Response (201):

{"id": 1}

GET /api/tasks

List tasks for a channel.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel ID
worktreesboolnotrue also lists the tasks of the channel’s worktree threads
threadsboolnotrue also lists the tasks of the channel’s plain (non-worktree) threads

Response (200):

[
  {
    "id": 1,
    "channel_id": "abc123",
    "schedule": "0 9 * * *",
    "type": "cron",
    "prompt": "Summarize today's PRs",
    "enabled": true,
    "next_run_at": "2026-01-02T09:00:00Z",
    "template_name": "daily-summary",
    "auto_delete_sec": 3600,
    "worktree": true,
    "origin_branch": "main",
    "update_before_run": true,
    "running": false,
    "thread_id": "thread_abc123",
    "workflow_name": "",
    "workflow_inputs": ""
  }
]

The running field indicates whether the task is currently being executed. It is set atomically when execution begins and cleared when it finishes. When workflow_name is set, the task triggers a workflow run instead of an agent prompt. plugin_id and plugin_action, omitted otherwise, are set on the task of a plugin action : it asks the plugin’s container in the channel to run the action. With worktrees=true or threads=true, a task of a listed thread carries the thread’s channel_name and dir_path, and channel_worktree: true when it is a worktree thread.

Errors: 400 if channel_id is missing.


GET /api/tasks/{id}

Get a single task by ID.

Path Parameters:

ParamTypeDescription
idint64Task ID

Response (200): Single task object (same schema as list items).

Errors: 400 if ID is invalid. 404 if task not found.


DELETE /api/tasks/{id}

Delete a scheduled task.

Response: 204 No Content

Errors: 400 if ID is invalid.


PATCH /api/tasks/{id}

Update one or more fields of a scheduled task. At least one field must be provided.

Request:

{
  "enabled": false,
  "schedule": "0 10 * * *",
  "type": "cron",
  "prompt": "Updated prompt",
  "auto_delete_sec": 7200,
  "worktree": true,
  "origin_branch": "develop",
  "update_before_run": true,
  "workflow_name": "validate",
  "workflow_inputs": "{}",
  "bash_script": "df -h | tail -1"
}

All fields are optional (use JSON null or omit). When enabled is provided, it is applied separately via SetTaskEnabled. Other fields are applied via EditTask. Set workflow_name to convert a prompt task into a workflow task, or bash_script to convert it into a bash task (clear either with an empty string to revert).

Response: 200 OK (empty body)

Errors: 400 if no fields provided or ID is invalid.


POST /api/tasks/{id}/run

Trigger an immediate execution of a task (“Run Now”). The task runs asynchronously in the background; the endpoint returns immediately.

Path Parameters:

ParamTypeDescription
idint64Task ID

Response: 202 Accepted (empty body)

Errors: 400 if ID is invalid. 404 if task not found. 409 Conflict if the task is already running.


POST /api/tasks/{id}/move

Move a task under another channel, thread or worktree thread of the same project, for example from a worktree thread to the root channel or back. The target must be under the same root channel as the task’s current owner, since its worktree, origin branch, templates and workflows belong to that project’s repo. Only the task’s owner changes: its schedule, prompt, worktree, origin_branch and other settings stay as they are. A thread deeper than one level resolves to its depth-1 parent, as on create.

On the local platform the task’s thread moves along with it. A thread with its own worktree (a worktree: true task) keeps its directory. A thread that shares its parent’s directory switches to the new parent’s, and its session transcript is copied there so the next run resumes it. Other platforms own their threads, so the task starts a fresh thread on its next run.

Path Parameters:

ParamTypeDescription
idint64Task ID

Request Body:

{"channel_id": "wt-thread-1"}

Response: 200 OK (empty body). Moving a task to the channel it’s already under is a no-op.

Errors: 400 if the ID or body is invalid, channel_id is missing, the target is the task’s own thread, or the target is in another project. 404 if the task, the target channel or the task’s current channel isn’t found. 409 Conflict if the task is running.


GET /api/tasks/{id}/runs

List recent run logs for a task (up to 50, newest first).

Path Parameters:

ParamTypeDescription
idint64Task ID

Response (200):

[
  {
    "id": 10,
    "task_id": 1,
    "status": "success",
    "response_text": "Completed successfully",
    "error_text": "",
    "started_at": "2026-01-02T09:00:00Z",
    "finished_at": "2026-01-02T09:01:30Z"
  }
]

The status field is one of "running", "success", or "failed".

Errors: 400 if ID is invalid.


Commands

POST /api/commands

Execute a slash command. The command is parsed and dispatched asynchronously through the interaction handler.

Request:

{
  "channel_id": "abc123",
  "author_id": "user123",
  "command": "schedule type=cron schedule='0 9 * * *' prompt='Daily standup'"
}
FieldTypeRequiredDescription
channel_idstringyesChannel context
author_idstringnoDefaults to "local-user"
commandstringyesCommand string

Supported commands:

CommandArgumentsDescription
tasks–List tasks
status–Show status
readme–Show README
template-list–List templates
iamtheowner–Claim ownership
tasktask_idShow task
canceltask_idCancel task
toggletask_idToggle task
stop[channel_id]Stop agent
template-addnameAdd template
scheduletype=... schedule=... prompt=...Schedule task
edittask_id [key=value ...]Edit task
allow_usertarget_id [role]Grant access
deny_usertarget_idRevoke access

Response: 204 No Content

Behavior notes:

  • The command string supports quoted arguments (single or double quotes).
  • Key-value pairs use key=value syntax.
  • Unknown commands return 400 with "unknown command".

Errors: 400 if channel_id or command is empty, or command is unknown. 503 if commands not configured.


Extra Directories

Extra directories are configured in the project config (.loop/config.json) via the extra_dirs field. They are automatically loaded when listing roots or resolving file paths.

GET /api/channels/{id}/roots

List all root directories for a channel: the primary dir_path followed by any extra directories from the project config.

Path Parameters:

ParamTypeDescription
idstringChannel ID

Response (200):

{
  "roots": [
    {"index": 0, "path": "/home/user/projects/my-project", "display_path": "~/projects/my-project", "name": "my-project"},
    {"index": 1, "path": "/home/user/projects/shared-lib", "display_path": "~/projects/shared-lib", "name": "shared-lib"},
    {"index": 2, "path": "/opt/proto", "display_path": "/opt/proto", "name": "proto"}
  ]
}

The first entry is always the primary dir_path. Subsequent entries are the extra directories in the order they were set. index is the value file endpoints take as root; display_path is path with the daemon user’s home dir shown as ~, which the root dropdowns show.

Errors: 404 if channel not found.


Files

All file endpoints resolve the channel’s dir_path from the database, falling back to ~/.loop/{channel_id}/work when the channel has no explicit path. When a channel has extra directories, file endpoints accept an optional root query parameter to select which root directory to operate on.

GET /api/channels/{id}/files

List directory contents for a channel’s working directory.

Query Parameters:

ParamTypeDefaultDescription
pathstring"."Relative path within the channel’s directory
rootint0Root directory index (0 = primary dir_path, 1+ = extra directories)

Response (200):

{
  "entries": [
    {"name": "src", "type": "dir"},
    {"name": "main.go", "type": "file", "size": 1234}
  ]
}

Behavior notes: Entries are sorted with directories first, then alphabetically by name (case-insensitive).

Errors: 400 if path validation fails (absolute path, .. traversal, symlink escape).


GET /api/channels/{id}/file

Read a file’s contents.

Query Parameters:

ParamTypeRequiredDescription
pathstringyesRelative path to the file
rootintnoRoot directory index (0 = primary, 1+ = extra directories)
refstringnoA commit hash (4–64 hex digits). Reads the file as it was in that commit (git show <ref>:./<path>) instead of from disk.

Response (200):

  • Text files: Content-Type: text/plain; charset=utf-8 with file contents as body.
  • Image files (.png, .jpg/.jpeg, .gif, .webp): Content-Type set to the matching image/* MIME with the raw bytes as the body. No X-File-Binary header — the desktop app uses this endpoint directly as <img src> so the browser handles caching and decoding.
  • Video and PDF files (.mp4, .webm, .mov, .pdf): streamed from disk with Content-Type set to the matching video/* or application/pdf MIME and Accept-Ranges: bytes, so Range requests return 206 Partial Content. The desktop app plays videos with <video src> and renders PDFs with its pdf.js viewer.
  • Other binary files: Empty body with X-File-Binary: true header and Content-Length set.

Behavior notes:

  • The image, video and PDF branches are matched on extension and run before null-byte binary detection.
  • Binary detection checks the first 512 bytes for null bytes.
  • Maximum file size is 5 MB (5,242,880 bytes). Larger files return 413. Videos and PDFs are exempt: they’re streamed, never buffered whole.
  • Path validation rejects absolute paths, .. traversal, and symlink escapes.
  • With ref, the file only has to exist in that commit — it may since have been deleted — so only the lexical checks apply; the response is text or X-File-Binary, never the image/video/PDF branches. The path is resolved relative to the channel’s directory, as on disk.

Errors: 400 if path or ref is invalid. 404 if file not found (at ref, when given). 413 if file too large.


PUT /api/channels/{id}/file

Write content to a file.

Query Parameters:

ParamTypeRequiredDescription
pathstringyesRelative path to the file
rootintnoRoot directory index (0 = primary, 1+ = extra directories)

Request body: Raw file content (not JSON). Maximum 5 MB.

Response (200):

{"ok": true}

Behavior notes:

  • Preserves original file permissions if the file already exists; defaults to 0644 for new files.
  • A symlink whose target resolves inside the root is followed: the write lands on the target and the link stays. A link that resolves outside the root, or points at nothing, is refused rather than written through.

Errors: 400 if path is invalid, a symlink leaves the root, or a symlink is dangling. 413 if content exceeds 5 MB.


DELETE /api/channels/{id}/file

Delete a file or directory.

Query Parameters:

ParamTypeRequiredDescription
pathstringyesRelative path to the file or directory
rootintnoRoot directory index (0 = primary, 1+ = extra directories)

Response (200):

{"ok": true}

Behavior notes: If the target is a directory, it is removed recursively (RemoveAll) with path traversal protection. If the target is a file, it is removed with Remove.

Errors: 400 if path is invalid. 404 if not found.


GET /api/channels/{id}/raw/{root}/{path...}

Serve a file’s raw bytes by path. The editor’s HTML preview uses this as the <base href> of the rendered page, so relative stylesheets, scripts and images resolve against the file’s directory. The root index is a path segment rather than a query parameter because relative URL resolution drops the query string.

Path Parameters:

ParamTypeDescription
rootintRoot directory index (0 = primary, 1+ = extra directories)
pathstringRelative path to the file

Response (200): The file’s bytes with Content-Type from its extension (application/octet-stream when unknown). Range requests return 206 Partial Content.

Behavior notes:

  • Responses carry X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox allow-scripts, so a page opened directly from this route runs in an opaque origin and can’t call the API with the app’s origin.
  • Path validation matches the other file routes: absolute paths, .. traversal and symlink escapes are rejected.

Errors: 400 if the root index or path is invalid, or the path is a directory. 404 if file not found.


POST /api/channels/{id}/dir

Create a directory (including nested intermediate directories).

Query Parameters:

ParamTypeRequiredDescription
pathstringyesRelative path to the directory to create
rootintnoRoot directory index (0 = primary, 1+ = extra directories)

Response (200):

{"ok": true}

Behavior notes: Uses MkdirAll to create the directory and any missing intermediate parents. Path validation walks up to the first existing ancestor to verify the path stays under the root directory.

Errors: 400 if path is invalid.


POST /api/channels/{id}/files/exists

Batched existence check for path candidates discovered in chat text or tool input. Used by the desktop app’s clickable file-link UX (see Chat: File Links ).

Request body:

{"paths": ["app/src/main.tsx", "/Users/me/dev/loop/README.md", "missing/file.go"]}

Response (200):

{
  "results": [
    {"path": "app/src/main.tsx",                      "exists": true,  "root_index": 0, "rel_path": "app/src/main.tsx"},
    {"path": "/Users/me/dev/loop/README.md",          "exists": true,  "root_index": 0, "rel_path": "README.md"},
    {"path": "missing/file.go",                       "exists": false}
  ]
}

Behavior notes:

  • Each candidate is resolved against the channel’s primary dir_path and any extra_dirs from project config, in order. The first root that contains the path wins; the root_index in the response refers to that root (compatible with the root query parameter on other file endpoints).
  • Relative paths are tried under each root via the same path-validation rules as the read/write endpoints (no absolute, no .. traversal, symlink-aware containment check).
  • Absolute paths are stat’d directly, then matched against each root’s resolved prefix to derive rel_path. Paths outside every root return exists: false.
  • Directories return exists: false — only regular files are reported as existing, since the link UX opens files in the editor.
  • The batch is capped at 200 paths per request; extras are silently dropped. Request body is limited to 64 KiB.

Errors: 400 if the request body is malformed or the channel’s directory cannot be resolved.


GET /api/channels/{id}/files/search

Fuzzy search for files across the channel’s primary dir_path and any extra_dirs. Used by the chat composer’s @ file picker.

Query Parameters:

ParamTypeDescription
qstringFuzzy query — every rune must appear in the relative path in order, case-insensitively. Empty q returns the first N entries.
limitintMax results to return. Default 30, max 100.

Response (200):

{
  "results": [
    {"root_index": 0, "rel_path": "app/src/main.tsx",    "name": "main.tsx"},
    {"root_index": 0, "rel_path": "internal/api/server.go", "name": "server.go"}
  ]
}

Behavior notes:

  • Walks all configured roots (primary dir_path first, then extra_dirs). root_index indicates which root the match came from (compatible with the root query parameter on file read/write endpoints).
  • Always skips .git, node_modules, vendor, .next, dist, build, __pycache__ subtrees.
  • Honors the top-level .gitignore in each root (basename and full-relpath patterns; negation patterns and nested .gitignore files are ignored).
  • Walk stops once limit matches have accumulated across all roots.

Errors: 400 if the channel’s directory cannot be resolved.


POST /api/channels/{id}/paste-image

Persist an image pasted into the chat input. The desktop app calls this from ChatInput’s onPaste handler whenever the clipboard contains an image file (see Chat: Paste Images ).

Request body:

{
  "data": "<base64-encoded image bytes>",
  "media_type": "image/png"
}
FieldTypeRequiredDescription
datastringyesStandard base64 (+/= alphabet) of the raw image bytes.
media_typestringyesOne of image/png, image/jpeg, image/gif, image/webp.

Response (200):

{"path": "/Users/me/dev/project/.loop/pastes/paste-20260518-112528-66a7b575.png"}

The returned path is absolute, so the agent’s built-in Read tool (which requires absolute paths) can pick the file up directly. The renderer inserts the path at the caret position in the chat input.

Behavior notes:

  • The file is written under <workspace>/.loop/pastes/; the directory is created with MkdirAll on first paste. .loop/ should be gitignored. Both are opened within the workspace: a .loop or pastes symlink leading out of it fails the paste with 500.
  • Filename format: paste-<YYYYMMDD>-<HHMMSS>-<8-hex>.<ext> (UTC timestamp, 4-byte random suffix). Collisions within the same second are avoided by the random suffix.
  • Extension is derived from media_type — .jpg for image/jpeg, .png/.gif/.webp otherwise.
  • Request body is limited to 5 MB of raw image bytes after base64 decoding. Larger payloads return 413.

Errors: 400 for malformed JSON, missing data, unsupported media_type, or invalid base64. 404 if the channel does not exist. 413 if the image exceeds 5 MB. 501 if file operations are not configured. 500 if the filesystem write fails.


Path Validation

All file operations validate the relative path against the channel’s root directory:

  1. Rejects absolute paths.
  2. Rejects .. traversal components.
  3. Resolves symlinks and verifies the real path stays under the root directory.
  4. For writes to nonexistent files, validates the parent directory instead.
  5. For directory creation, walks up to the first existing ancestor (allows creating nested directories).

Diff

GET /api/channels/{id}/diff

Get git diff information for a channel’s working directory. Includes both tracked changes and untracked files, and tags each entry with the bucket it came from (staged / unstaged / untracked / conflict).

Query Parameters:

ParamTypeDescription
sourcestringWhen provided with target, switches to branch-to-branch diff mode (git diff source..target). status is omitted in this mode.
targetstringBranch / ref name for branch-to-branch diff mode.
commitstringA commit hash (4–64 hex digits). Switches to single-commit mode: the changes that commit introduced. Takes precedence over source/target.
rootintRoot directory index (0 = primary dir_path, 1+ = extra directories from project config). Defaults to 0.

Response (200) — uncommitted mode:

{
  "files": [
    {"path": "main.go",   "additions": 5, "deletions": 2, "binary": false, "status": "staged"},
    {"path": "main.go",   "additions": 1, "deletions": 0, "binary": false, "status": "unstaged"},
    {"path": "new.txt",   "additions": 4, "deletions": 0, "binary": false, "status": "untracked"},
    {"path": "image.png", "additions": 0, "deletions": 0, "binary": true,  "status": "untracked"}
  ],
  "diff": "diff --git a/main.go b/main.go\n...",
  "staged_diff": "diff --git a/main.go b/main.go\n...",
  "unstaged_diff": "diff --git a/main.go b/main.go\n...",
  "untracked_diff": "diff --git a/new.txt b/new.txt\n...",
  "conflict_diff": "",
  "total_additions": 10,
  "total_deletions": 2
}

File entry fields:

FieldTypeDescription
pathstringFile path relative to the repo root
old_pathstringOriginal path (set when the file was renamed; omitted otherwise)
additionsintLines added in this entry
deletionsintLines deleted in this entry
binaryboolTrue for binary files
statusstring"staged", "unstaged", "untracked", or "conflict". Omitted in branch-to-branch diff mode. A partially-staged file appears twice — once as staged and once as unstaged — in that order.

Behavior notes:

  • Uncommitted mode (default): runs git diff --cached (staged), git diff (unstaged), and git ls-files --others --exclude-standard (untracked). Conflicts are surfaced via git diff --diff-filter=U.
  • The frontend parses staged_diff / unstaged_diff / untracked_diff / conflict_diff independently so the per-bucket status tag survives partial staging (a path that appears in both staged and unstaged buckets would otherwise collide in a single parsed-by-path lookup).
  • Branch-to-branch mode (?source=branchA&target=branchB): a single git diff is returned in diff; the per-status fields and the status tag on each file are omitted.
  • Single-commit mode (?commit=<sha>): runs git show --diff-merges=first-parent, so a merge commit is diffed against its first parent and a root commit against the empty tree. The response has the branch-to-branch shape. Anything other than a hex hash returns 400; a hash that doesn’t resolve to a commit returns 404.
  • Untracked files generate synthetic diff patches (all lines as additions). Binary detection checks the first 512 bytes for null bytes.
  • If the directory is not a git repo, returns an empty files array.
  • Files are sorted by (path, status priority) — conflict < staged < unstaged < untracked.
  • The root parameter mirrors the same field on file endpoints: 0 (default) targets the channel’s primary dir_path; 1+ indexes into extra_dirs from the project’s .loop/config.json. Both uncommitted and branch-to-branch modes honor root.

Errors: 404 if channel not found. 400 if root is non-numeric, negative, or out of range.


Pull Request

GET /api/channels/{id}/pr

Look up the pull request whose head branch matches the channel’s current branch: a GitHub PR via gh, or a GitLab merge request via glab when the repo’s origin is a GitLab host. Shells out against the channel’s working directory.

Response (200, PR found):

{
  "present": true,
  "pr": {
    "number": 24,
    "url": "https://github.com/owner/repo/pull/24",
    "base_ref": "main",
    "head_ref": "feat/git-panel-pr-aware",
    "state": "OPEN",
    "title": "feat(git-panel): default diff base to PR target and live-update branch",
    "is_draft": false
  }
}

A GitLab merge request has "kind": "mr", its IID as number, and GitLab’s lowercase state (opened, merged, closed); kind is absent for a GitHub PR.

Response (200, no PR):

{"present": false}

Behavior notes:

  • The lookup uses gh pr view --json number,url,baseRefName,headRefName,state,title,isDraft and falls back to a gh pr list query when pr view reports no match.
  • The gh account is picked by the github.gh_user config key (mergeable per-project via .loop/config.json); empty falls back to whichever account gh currently has active.
  • A repo without an origin remote looks nothing up. An origin on a GitLab host (its name contains gitlab, or the glab config lists it) runs glab mr list --source-branch <branch> --all, newest first; gitlab.glab_config_dir picks the config, so the account, it uses. Any other host goes to gh, GitHub Enterprise included.
  • Environmental failures (gh or glab not installed, network) degrade to present: false rather than a 5xx so the UI hides the PR chip silently.
  • A PR/MR found has the PR watcher poll the thread’s checks.

Errors: 404 if channel not found.


PR watching

Loop watches the checks of a thread’s PR/MR and sends failures on the agent’s own commits back to it (see pr.md ). These routes are owner-only: the agent scope leaves them out. All of them return 501 with PR watching not configured when the daemon runs without it.

GET /api/channels/{id}/pr/status

The status of the channel’s PR/MR, as the last channel.pr event reported it, or read from the forge when nothing has been reported yet.

Response (200):

{
  "present": true,
  "pr": {
    "channel_id": "thread-id",
    "kind": "mr",
    "number": 57,
    "url": "https://gitlab.example.com/example/group/project/-/merge_requests/57",
    "head_sha": "4f1c2d9e8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e",
    "base_ref": "main",
    "checks": {"total": 7, "passed": 5, "failed": 2, "pending": 0},
    "failed": [{"id": "981", "name": "lint", "group": "test", "url": "https://gitlab.example.com/example/group/project/-/jobs/981", "run_id": "981"}],
    "merge_state": "blocked",
    "auto_merge": false,
    "merge_methods": ["squash", "merge"],
    "merge_method": "squash",
    "pr_attention": "fixing",
    "auto_fix": true,
    "auto_fix_default": true,
    "attempts": 1,
    "max_attempts": 3,
    "reason": "fix_sent",
    "checkout_sha": "4f1c2d9e8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e",
    "unresolved_threads": 2
  }
}

{"present": false} when the branch has no PR/MR. The fields are those of the channel.pr event.

POST /api/channels/{id}/pr/fix

Sends the PR/MR’s current failure to the thread’s agent now, whoever made the commit and whatever the auto-fix guards say. Returns 204.

Errors: 404 when the branch has no PR/MR, 409 when no check failed (a merge conflict alone is never sent), 502 when the forge can’t be read.

POST /api/channels/{id}/pr/rerun

Re-runs the PR/MR’s failed checks on the forge now: each failed GitHub workflow run once (gh run rerun --failed), or each failed GitLab job. Returns 204.

Errors: 404 when the branch has no PR/MR, 409 when nothing failed, 502 when the forge can’t be read or refuses.

GET /api/channels/{id}/pr/log?check={id}

The tail of the log of a failed check, one of the failed the status last reported.

Response (200):

{"log": "--- FAIL: TestParse (0.00s)\n..."}

Errors: 404 when the branch has no PR/MR or check isn’t one of the failed checks, 502 when the forge can’t be read.

PUT /api/channels/{id}/pr/auto-fix

Overrides pr.auto_fix for the thread. Returns 204.

Request body:

{"auto_fix": false}

null drops the override, so the config’s setting applies again.

Errors: 400 for a malformed body, 502 when the setting can’t be saved.

POST /api/channels/{id}/pr/merge

Merges the thread’s PR/MR now, or arms “merge when ready”. Returns 204.

Request body:

{"when_ready": true, "method": "squash"}
FieldTypeDescription
when_readyboolArm “merge when ready” instead of merging now
methodstringsquash, merge or rebase. Empty uses pr.merge_method, else the first the repo allows

Behavior notes:

  • Armed, the merge goes through the forge’s own auto-merge when the repo allows it (gh pr merge --auto, or GitLab’s auto-merge). Otherwise Loop merges once the checks pass and the PR/MR can merge cleanly. Loop disarms its own merge when someone other than the agent pushes a new head.
  • A method the repo doesn’t allow fails with 502.

Errors: 400 for a malformed body, 404 when the branch has no PR/MR, 502 when the forge refuses or can’t be read.

DELETE /api/channels/{id}/pr/merge

Cancels the “merge when ready” Loop armed, turning the forge’s auto-merge off when that’s what it armed. An auto-merge turned on outside Loop is left alone. Returns 204.

Errors: 404 when the branch has no PR/MR, 502 when the forge refuses.


Branches & Worktrees

GET /api/channels/{id}/branches

List local git branches and worktrees for a channel’s directory.

Query Parameters:

ParamTypeDefaultDescription
rootint0Root directory index (0 = primary dir_path, 1+ = extra directories from project config)

Response (200):

{
  "branches": ["main", "feature/foo"],
  "current": "main",
  "worktrees": [
    {"path": "/project/.worktrees/wt1", "branch": "feature/bar", "thread_id": "thread-id-if-imported"}
  ]
}

Behavior notes:

  • Branches checked out in other worktrees are excluded from the branches list (git won’t allow switching to them).
  • The main worktree is excluded from the worktrees list.
  • thread_id is populated when the worktree has been imported as a thread (via POST /api/worktrees or POST /api/worktrees/import).
  • The root parameter mirrors the same field on /api/channels/{id}/diff and /api/channels/{id}/commits: 0 (default) targets the channel’s primary dir_path; 1+ indexes into extra_dirs from the project’s .loop/config.json. Each extra root is treated as an independent repo.

Errors: 400 if root is non-numeric, negative, or out of range.

POST /api/channels/{id}/branches/switch

Switch the git branch in a channel’s working directory.

Request: {"branch": "feature/foo"}

Response (200): {"ok": true}

Errors: 400 if branch name is invalid or missing. 500 if git checkout fails (e.g. uncommitted changes).

POST /api/channels/{id}/branches/create

Create and checkout a new branch.

Request: {"name": "feature/new", "from": "main"} (from is optional)

Response (200): {"ok": true}

GET /api/channels/{id}/commits

List commit history for a channel’s git repository.

Query Parameters:

ParamTypeDefaultDescription
branchstringHEADBranch name to list commits from
limitint50Maximum number of commits to return (max 200)
skipint0Number of commits to skip (for pagination)
rootint0Root directory index (0 = primary dir_path, 1+ = extra directories from project config)

Response (200):

{
  "commits": [
    {
      "hash": "abc123def456...",
      "short": "abc123d",
      "subject": "feat: add new feature",
      "author": "John Doe",
      "date": "2026-03-30 14:22:01 +0000"
    }
  ]
}

Behavior notes:

  • Commits are returned in reverse chronological order (newest first).
  • On empty repositories (no commits yet), returns {"commits": []} instead of an error.
  • Branch names are validated against a safe character set (alphanumeric, slashes, hyphens, dots, underscores).
  • The skip parameter enables lazy pagination — the frontend loads pages of 50 commits and fetches more on scroll.
  • The root parameter mirrors the same field on /api/channels/{id}/diff and other file endpoints: 0 (default) targets the channel’s primary dir_path; 1+ indexes into extra_dirs from the project’s .loop/config.json.

Errors: 400 if branch name is invalid, or if root is non-numeric / negative / out of range.

POST /api/worktrees

Create a git worktree as a new thread. The worktree gets its own branch (worktree/<name>) based on the selected branch, inherits the parent’s session for --fork-session, and appears in the sidebar as a thread.

Request:

{
  "channel_id": "parent-channel-id",
  "branch": "main",
  "name": "optional-name",
  "author_id": "optional-user-id",
  "message": "optional first prompt"
}

If message is provided, the bot posts it as a self-mention into the new thread to trigger a runner immediately with that prompt — same auto-trigger semantics as POST /api/threads.

Response (201):

{
  "thread_id": "new-thread-id",
  "worktree_path": "/project/.worktrees/wt-abc123"
}

Behavior notes:

  • Creates git worktree add -b worktree/<name> <path> <branch> so any branch can be used as base, including the currently checked out one.
  • Copies the parent’s Claude session file to the worktree’s project dir (~/.claude/projects/<encoded-path>/) so --resume --fork-session works on the first message.
  • The thread’s DirPath points to the worktree directory; Worktree flag is set to true.
  • Container mounts include the parent project directory so git worktree references resolve correctly.

POST /api/worktrees/import

Import an existing git worktree as a thread. Unlike POST /api/worktrees which creates a new worktree, this associates an already-existing worktree directory with a thread.

Request:

{
  "channel_id": "parent-channel-id",
  "worktree_path": "/project/.worktrees/existing-wt"
}

Response (201):

{
  "thread_id": "new-thread-id",
  "worktree_path": "/project/.worktrees/existing-wt"
}

Behavior notes:

  • Validates that worktree_path is a real git worktree (checked against git worktree list --porcelain).
  • Idempotent: if a thread already exists for the worktree path, returns it with 200 instead of creating a duplicate.
  • Copies the parent’s Claude session file to the worktree’s project dir for --fork-session support.
  • Thread name is derived from the worktree directory name and branch: <dirname> (<branch>).

DELETE /api/worktrees

Remove a git worktree from disk and optionally delete its associated thread.

Request Body:

FieldTypeRequiredDescription
channel_idstringyesParent channel ID that owns the worktree
worktree_pathstringyesAbsolute path to the worktree directory
thread_idstringnoThread ID to delete (if the worktree was imported as a thread)

Response: 204 No Content on success.

Behavior notes:

  • Runs git worktree remove --force on the worktree path, then git worktree prune.
  • If thread_id is provided, also deletes the thread record from the database and broadcasts a channel.deleted event. As with DELETE /api/threads/{id}, the thread’s hidden learn and explain threads go with it: their running pass or explanation is cancelled, the queued ones are dropped, and their forked session files are deleted.
  • If the git worktree removal fails (e.g. path already gone), returns 500.

Errors: 400 if channel_id or worktree_path is missing, or if the channel is not found.


Quality

See Quality for the engine’s full architecture, metric definitions, and rule semantics.

POST /api/channels/{id}/quality/scan

Kick a quality scan for the channel. The request returns immediately; the full report ships over the WebSocket as a quality.scanned event.

Response (202 Accepted):

{ "status": "started" }

When a scan is already in flight for this channel, returns {"status": "in_progress"} without queueing or replacing — the engine coalesces concurrent triggers per channel.

Errors: 501 if the quality scanner is not configured.


GET /api/channels/{id}/quality/snapshot

Fetch the persisted quality snapshot. Returns the row for the channel’s current branch first; on miss falls back to the most recent snapshot on any branch with branch_mismatch: true so the panel can render a banner.

Response (200):

{
  "dir_path": "/work",
  "branch": "main",
  "current_branch": "main",
  "branch_mismatch": false,
  "signal": 6532,
  "geo_mean": 0.6532,
  "scanned_at": "2026-04-30T17:01:23Z",
  "metrics": [
    { "name": "modularity", "score": 0.71, "raw": 0.42 }
  ],
  "tiles": [
    { "path": "internal/foo/bar.go", "loc": 312, "deficit": 0.18, "metric_deficits": { "depth": 0.12 }, "top_reason": "depth" }
  ]
}

Errors: 404 if no snapshot has ever been recorded for the channel. 501 if the snapshot reader is not configured.


GET /api/channels/{id}/quality/complexity

Per-function complexity hotspots from the cached graph (cyclomatic, cognitive, max nesting, parameter count, LOC). Recomputes the metric using the channel’s effective quality.complexity thresholds — same numbers the engine produced during scan. The function list is sorted worst-first; per-dimension scores follow a saturating T/raw curve past threshold, so badly-saturated functions stay ranked against each other.

Query Parameters:

ParamTypeRequiredDescription
limitintnoMax functions per page (default 50, max 100).
offsetintnoStart offset into the worst-first list (default 0).

Response (200):

{
  "score": 0.903,
  "raw": 338,
  "total_functions": 8388,
  "over_threshold": 338,
  "histogram": {
    "cyclomatic": { "ok": 8201, "warn": 138, "crit": 48 },
    "cognitive":  { "ok": 8171, "warn": 141, "crit": 75 },
    "nesting":    { "ok": 8370, "warn":  17, "crit":  0 },
    "params":     { "ok": 8347, "warn":  30, "crit": 10 },
    "loc":        { "ok": 8219, "warn": 135, "crit": 33 }
  },
  "functions": [
    {
      "path": "internal/orchestrator/executor.go",
      "name": "ExecuteTask",
      "start_line": 91,
      "cyclomatic": 105,
      "cognitive":  243,
      "max_nesting": 4,
      "param_count": 2,
      "loc": 485,
      "score": 0.062
    }
  ],
  "offset": 0,
  "limit": 50,
  "returned": 1
}

raw is the count of over-threshold functions (mirrors over_threshold). Histogram buckets are ok (≤ T), warn (T..2T), crit (> 2T). score is the LOC-weighted mean of per-function scores.

Errors: 400 on invalid limit / offset. 404 if no graph is cached for the channel (run a scan first). 501 if the quality engine is not configured.


GET /api/channels/{id}/quality/clones

Clone clusters from the cached graph. SimHash fingerprints over normalised function-body shingles are bucketed by Hamming distance — see quality.clones.max_distance. Clusters with one member are dropped. The list is sorted by total LOC descending.

Query Parameters:

ParamTypeRequiredDescription
limitintnoMax clusters per page (default 25, max 50).
offsetintnoStart offset (default 0).

Response (200):

{
  "score": 0.842,
  "raw": 412,
  "duplicated_loc": 412,
  "total_loc": 33559,
  "cluster_count": 27,
  "clusters": [
    {
      "members": [
        { "path": "internal/api/foo_handler.go", "name": "handleFoo", "start_line": 91, "end_line": 142, "loc": 52 },
        { "path": "internal/api/bar_handler.go", "name": "handleBar", "start_line": 91, "end_line": 142, "loc": 52 }
      ],
      "loc": 104,
      "max_distance": 1
    }
  ],
  "offset": 0,
  "limit": 25,
  "returned": 1
}

raw is duplicated_loc, the LOC counted as duplicate (every member’s LOC except one representative per cluster). score is 1 - duplicated_loc/total_loc.

Errors: 400 on invalid limit / offset. 404 if no graph is cached for the channel. 501 if the quality engine is not configured.


Review

See review.md for the full lifecycle. Endpoints below return 501 review service not configured if the daemon was started without gh available or without a worktree provider wired in.

GET /api/channels/{id}/review/prs

List the open pull requests in the repo backing the channel’s working directory. The FE renders these as a picker so the user can click a row to auto-load instead of pasting a PR number or URL.

Response: {"prs": [{"number": 42, "url": "...", "base_ref": "main", "head_ref": "feat-x", "state": "OPEN", "title": "Add X", "is_draft": false}, ...]} — capped at 100.

Errors: 400 if the channel has no dir_path. 404 if the channel does not exist. 500 on gh failure. 503 (gh CLI not installed) when the gh binary is missing. 501 if the review service is not configured.

POST /api/channels/{id}/review/load

Load a PR’s diff into a local worktree under the channel’s dir_path (.worktrees/pr-<n>-<channel id>, one per channel). Body: {"pr_number": 42}. Replaces any existing session for the channel.

Response: {"present": true, "session": { ... }} — the full session, mirroring review.Session in internal/review/session.go.

Errors: 400 on invalid pr_number or missing dir_path. 404 if the PR does not exist. 500 on gh/git failure.

POST /api/channels/{id}/review/start

Do what the Review panel’s Run button does, in one call: load the PR unless the session is already ready on it, record the model and effort on the session, and start the review workflow. The Review panel’s Run button and the run_review MCP tool use it.

Body: {"pr_number": 42, "model": "opus", "effort": "high", "mode": "review", "max_iterations": 1, "fork": "chat", "scope": "auto"}. Only pr_number is required. model and effort default to the channel’s own overrides, and empty after that means the config default. With "config_defaults": true (the panel) the channel’s overrides are skipped, so an empty model or effort is the config default right away. mode is review (default, the review-loop workflow) or review-fix (review-fix-loop). max_iterations is 1–10, default the project’s review.max_iterations (3 unless set). fork picks the session the run forks, as PUT .../review/fork does: chat (the channel’s chat session), fresh (a new session) or a Claude session id. It is stored on the review session; empty keeps the session’s choice, which is chat for a newly loaded PR. scope is auto, full or incremental, stored on the session as PUT .../review/scope does; empty keeps the session’s choice.

Response (201): {"run_id": "...", "workflow_name": "review-loop", "model": "opus", "effort": "high", "session": { ... }}. A Review panel on the channel picks the run up from its workflow.run_started event, or from the run list when it opens later.

Errors: 400 on an invalid body, mode, max_iterations, effort or scope. 404 if the channel or the PR does not exist. 409 while the channel has a review run in flight, a session loading or reviewing, a review-loop / review-fix-loop run running or paused, or another start in progress. Load errors as for POST .../review/load .

GET /api/channels/{id}/review

Return the channel’s review session, or {"present": false} if none. ?diff=false leaves out raw_diff, by far the largest field, for callers that only want the comments. Each agent comment’s transcripts lists the Claude transcripts of the agent passes that reported or changed it, oldest first (details ). A real agent comment carries its impact (blocking or nice_to_have), and a GitHub reply carries reply_to, the GitHub id of its thread’s first comment.

The session also carries:

  • reviewed_sha: the commit the latest finished review run or verification step judged.
  • approved_sha: the commit the PR was approved at from the panel (POST .../review/approve ).
  • threads: the verification step’s assessments of the unresolved GitHub threads a person started, keyed by the GitHub id of the thread’s first comment: {"88": {"status": "open", "impact": "blocking", "reason": "..."}}.
  • review_id and review_runs: the latest review, which a POST .../review/start begins, and the history ids of its runs, one per iteration of its loop. An agent comment’s run_id is the run that reported it, so the comments whose run_id is in review_runs are the latest review’s.
  • review_head: the commit a review mode start pinned its loop to. While that review-loop run is running or paused, the review’s runs, Sync and dedup check out this commit instead of the PR’s newer head. Absent for review-fix starts, which follow the head.

Next to session, the response carries the PR outcome , worked out on each GET:

{"state": "approve_with_nits", "blocking": [], "nits": ["<id>"], "pending": [], "reason": "", "reviewed_sha": "abc123"}

state is approve, approve_with_nits, request_changes or unknown. blocking, nits and pending list the comments that decide it (a GitHub thread by its first comment’s id); reason says why it is unknown.

DELETE /api/channels/{id}/review

Remove the channel’s session and delete the on-disk worktree, stopping a run in progress. Broadcasts review.status with idle when there was a session. Idempotent — 204 whether or not one exists.

POST /api/channels/{id}/review/run

Start an agent review pass. Returns 202 {"status":"started"} and the run continues in the background, streaming review.comment and review.status events over the WebSocket.

A concurrent call while a run is in flight returns 202 {"status":"in_progress"} without restarting.

Once the agent is done, and before the session turns ready, the daemon runs the dedup pass over the comments the run added, checking each against every other comment in the session. The session’s superseded field then maps each comment it deleted to the comment it was folded into; a new run clears it. A run that added no agent comment skips the pass, and a failed pass is only logged.

When review.verify is on (the default), the run instead ends with the verification step : the session goes from reviewing to verifying and turns ready once it is done. The step’s dedup pass covers the whole session. An optional body {"verify": false} skips the step for this run and runs the per-run dedup pass above (loop review:run --no-verify). An empty body or {} keeps the config’s choice. A body that isn’t valid JSON gets a 400.

The body’s optional scope ("" for auto, full or incremental) overrides the session’s scope for this run (loop review:run --scope); an unknown one gets a 400. The run resolves it against the PR’s review history and reports what it resolved to as the session’s last_run_scope and, for an incremental run, last_run_from_sha. If resolving fails, the run reviews the whole PR.

If the session is configured to fork (see below), the chosen session’s transcript is copied into the worktree’s Claude project dir before the agent starts, and the run is launched with --resume <id> --fork-session.

Errors: 404 if no session. 409 if the session is not in ready status. 400 if the configured fork cannot be resolved or staged (no chat session yet, unknown session id, transcript missing on disk). 501 if the review agent is not wired.

POST /api/channels/{id}/review/verify

Run the verification step over a ready session (loop review:verify), as a review run does once it is over. Returns 202 {"status":"started"}. The step goes on in the background, with the session in status verifying:

  1. The dedup pass runs over the whole session. If it fails, the failure is logged and the step goes on.
  2. A read-only agent reads, in the PR worktree at the reviewed commit, the code each agent comment points at. It records a verdict (real, false_positive, already_fixed) and a reason on each one through update_review_comment, with the impact (blocking or nice_to_have) of a real one. It also assesses each unresolved GitHub thread a person started through assess_review_thread: addressed, or still open with an impact. Those two tools are the only changes the agent may make. The prompt is review.verify_prompt, or the built-in one, followed by the PR, the reviewed commit, the checkout and the dedup outcome.
  3. Loop records the head as reviewed (reviewed_sha) and posts the agent’s reply, a per-comment summary, to the channel, followed by Outcome: and the PR outcome in one line.

The step drops the previous step’s thread assessments when it starts. The session then turns ready. If the agent fails or times out (15 minutes), it turns error; Stop settles it ready. A session with no agent comment and no unresolved GitHub thread a person started has nothing to check: no agent runs, and the head is recorded as reviewed. When review.verify is off, the endpoint returns 202 {"status":"disabled"} and does nothing. It is in the agent token scope.

Errors: 404 if no session. 409 if the session has no worktree, a review run or dedup pass is in progress, or the session isn’t ready. 403 if review is disabled. 501 if the review agent is not wired.

POST /api/channels/{id}/review/dedup

Fold duplicate findings. Every comment in the session, across files, goes to a read-only agent run (no Bash, no edits, strict MCP config) when at least one is the agent’s, even a lone one, since it still needs a verdict. The model clusters them by root cause, including a cause and its symptom (even when the symptom has a narrower fix of its own), or the same issue anchored in two files, and picks the most severe and specific one to keep. The daemon deletes the others the same way DELETE /review/comments/{cid} does, which includes removing them from the PR if they were pushed. Each deletion is broadcast as review.comment_removed. The model’s note on what the dropped comments added is appended to the kept comment when that is an unpushed agent finding, and broadcast as review.comment_updated. Only agent findings are ever deleted: a GitHub comment can be the one kept, but is never dropped. The run is synchronous, and while it runs the session holds the channel’s review-run slot and shows status reviewing. It is in the agent token scope, so an agent can call it from its container through loop review:dedup. Every review run already folds the comments it added (see POST .../review/run ); this regroups the whole session.

Response:

{
  "removed": ["<id>"],
  "clusters": [{"kept": "<id>", "removed": ["<id>"], "reason": "...", "note": "...", "note_added": true}],
  "related": [{"ids": ["<id>", "<id>"], "reason": "..."}],
  "moved": [{"id": "<id>", "from": 145, "to": 147}],
  "trimmed": [{"id": "<id>", "covered_by": "<id>", "reason": "..."}],
  "verdicts": [{"id": "<id>", "verdict": "false_positive", "reason": "..."}],
  "checked": 11,
  "errors": ["<id>: <msg>"]
}

clusters lists each merged group with the ids actually removed; a group whose deletes all failed is left out. note_added is false when the kept comment is a GitHub or pushed one, which is never edited. related groups findings about the same code path that need separate fixes; nothing is deleted for them. moved lists the unpushed agent findings the model re-anchored to the statement they are about, at most 20 lines from where they were; each move is broadcast as review.comment_updated. trimmed lists the unpushed agent findings that bundled several issues and were rewritten to the ones no other comment covers; covered_by is the comment that reports the part cut out. Each is broadcast as review.comment_updated. verdicts lists the kept agent findings the model checked against the code: real, false_positive or already_fixed, with a one-sentence reason. A move or a verdict counts only when a Read tool call of the pass showed that comment’s line, and for a move the line it moves to, in the PR worktree (the parent checkout’s copy doesn’t count; for a LEFT-side comment any read of the file does); the others are dropped. Each verdict is stored on the comment as verdict and verdict_reason (pushed findings included; nothing is deleted for a verdict) and broadcast as review.comment_updated. checked is the number of comments shown to the model (0 when there is nothing to fold, in which case no agent runs). errors lists the deletions that failed; those comments stay.

Errors: 404 if no session. 409 if the session has no worktree, is not ready, or a review run is in flight. 400 if the channel has no dir_path. 403 if review is disabled for the project. 500 if the refresh from GitHub fails, the agent run fails, or its reply has no parseable JSON. 501 if the review service or agent is not wired.

POST /api/channels/{id}/review/stop

Stop the review run in flight, or a dedup pass , which holds the same slot. Returns 202 {"status":"stopping"} at once. The agent is stopped, and the session goes back to ready once it is gone, keeping the findings reported so far; review.status says when. A stopped run is no error. A POST .../review/run before then still answers 202 {"status":"in_progress"}, and a stopped dedup pass answers its own caller 500.

Errors: 404 if no session. 409 if no review run or dedup pass is in flight. 501 if the review service is not wired.

PUT /api/channels/{id}/review/fork

Choose which Claude session the next review run forks from. Body: {"mode": "", "session_id": ""} where mode is one of:

modeBehaviour
""Fresh session; the reviewer sees only the diff.
"current"Fork whatever session the channel’s chat is on when the run starts — the default for a newly loaded PR. With no chat session yet, the run starts fresh.
"custom"Fork session_id.

It is always a fork, never a resume — review turns never land in the chat session the user is still talking to. session_id is stored only for custom and cleared on any other mode.

The choice lives on the review session (not on the run request) because the desktop app’s Run button dispatches a workflow whose loop review:run step has nowhere to carry per-run options. It survives a daemon restart, which loads the session’s PR again with its choices (see Review history ), and resets when a PR is loaded.

Response: {"present": true, "session": { ... }} — the updated session.

Errors: 400 on invalid JSON, an unknown mode, or custom without a session_id. 404 if the channel has no review session. 501 if the review service is not configured.

PUT /api/channels/{id}/review/agent

Choose the model and reasoning effort the next review run uses. Body: {"model": "", "effort": ""}. model is any Claude model id, passed to the CLI verbatim; effort is one of low, medium, high, xhigh, max. Empty inherits the config’s claude_model / claude_effort. Values are trimmed. Stored on the review session like the fork choice, and reported back as the session’s model / effort.

Response: {"present": true, "session": { ... }} — the updated session.

Errors: 400 on invalid JSON or an unknown effort. 404 if the channel has no review session. 501 if the review service is not configured.

PUT /api/channels/{id}/review/scope

Choose what the next review run reviews. Body: {"scope": ""}, where scope is "" (auto), "full" or "incremental"; see Incremental re-review . Stored on the review session like the fork choice, and reported back as the session’s scope.

Response: {"present": true, "session": { ... }} — the updated session.

Errors: 400 on invalid JSON or an unknown scope. 404 if the channel has no review session. 501 if the review service is not configured.

GET /api/channels/{id}/review/stats

The review stats of the channel’s repo (its main dir, so every worktree of the repo counts), from the review history. ?days= sets the window (default 90); ?pr= limits them to one PR. In the agent token scope.

{
  "runs": {"total": 4, "completed": 3, "failed": 1, "cancelled": 0, "running": 0,
           "full": 3, "incremental": 1, "prs": 2, "median_ms": 192400, "p90_ms": 310000},
  "findings": {"added": 10, "folded": 2, "deleted": 1, "pushed": 4,
               "real": 5, "false_positive": 1, "already_fixed": 1, "unjudged": 3,
               "blocking": 2, "nice_to_have": 3, "resolved": 2, "outdated": 1, "false_positive_rate": 0.14},
  "by_model": [{"key": "opus", "runs": 3, "added": 8, "...": "..."}],
  "by_effort": [],
  "by_scope": [],
  "by_category": []
}

The durations are of completed runs. false_positive_rate is false positives over the findings with a verdict. blocking and nice_to_have split the real findings by impact. Each breakdown row carries the findings counts for its key (an empty key for runs that recorded none, and for findings reported outside a run); category rows have runs: 0, since a run reports findings of many categories.

Errors: 400 if days or pr isn’t a positive number. 404 if the channel has no dir_path. 501 if the daemon keeps no review history.

DELETE /api/channels/{id}/review/comments/{cid}

Delete one comment from the session, and from the PR when it has a GitHub copy (a pushed agent comment, or a GitHub comment the configured gh user wrote). Broadcasts review.comment_removed. Response: 204.

Errors: 403 for a GitHub comment by someone else, for any GitHub comment with an agent token, or when review is disabled. 404 if session or comment id is unknown. 500 on gh failure; the comment is kept. 501 if the review service is not configured.

PATCH /api/channels/{id}/review/comments/{cid}

Replace a comment’s body, e.g. to fold in what a duplicate adds, and/or record a verdict on it, replacing the dedup pass’s. Body: {"body": "...", "verdict": "real", "impact": "nice_to_have", "verdict_reason": "..."}; give body, verdict or both. verdict is real, false_positive or already_fixed. impact is blocking or nice_to_have and goes with real only; a verdict without one clears it. Only an unpushed agent comment’s body can change; a verdict goes on any agent comment. Broadcasts review.comment_updated.

Response: the updated comment.

Errors: 400 on invalid JSON, an unknown verdict, neither a body nor a verdict, or an impact that is unknown or not with real. 404 if session or comment id is unknown. 409 for a GitHub comment, or a body for a pushed one. 501 if the review service is not configured.

POST /api/channels/{id}/review/comments/{cid}/assess

Record the verification step’s call on a GitHub thread a person started, by the id of its first comment. Body: {"status": "open", "impact": "blocking", "reason": "..."}. status is addressed (a later commit or a reply settles it) or open; an open thread needs impact (blocking or nice_to_have), an addressed one takes none. The assessment is stored in the session’s threads and counts toward the outcome . An agent may call it for its own channel only.

Response: the assessment recorded, its reason trimmed.

Errors: 400 on invalid JSON, an unknown status, an open thread without an impact, or an addressed one with one. 404 if session or comment id is unknown. 409 for an agent comment (it takes a verdict instead) or a reply. 501 if the review service is not configured.

POST /api/channels/{id}/review/approve

Approve the PR on GitHub at the session’s head commit, as the configured gh user: the panel’s Approve on GitHub. It runs gh api repos/{owner}/{repo}/pulls/{N}/reviews with event=APPROVE, commit_id set to the head and the body as the review’s text, records the head as the session’s approved_sha and broadcasts review.approved. Owner only: an agent token gets 403.

Body (optional): {"body": "...", "force": false}. Without force, the outcome has to be approve or approve_with_nits.

Response: {"approved_sha": "<head sha>"}.

Errors: 400 on invalid JSON. 404 if no session. 409 if the session has no PR or head SHA, the outcome doesn’t allow it and force is off, or the PR’s head moved past the session’s (Sync and verify again). 403 if review is disabled. 502 when reading the PR head or the repo fails, or GitHub refuses the approval (an own PR, a merged one), with GitHub’s message. 501 if the review service is not configured.

POST /api/channels/{id}/review/comments/{cid}/push

Push one comment to the PR via gh api /repos/{owner}/{repo}/pulls/{N}/comments. Flips pushed=true on the session on success.

Response: {"pushed": true} (or {"pushed": true, "already": true} if already pushed).

Errors: 404 if session or comment id is unknown. 500 on gh failure.

POST /api/channels/{id}/review/push-all

Push every unpushed comment in the session, except those the dedup pass marked false_positive or already_fixed; skipped counts them. Errors are accumulated rather than short-circuiting — one bad comment does not block the rest. Returns 409 while the session is verifying, so it waits for the verdicts. ?impact=blocking (or nice_to_have) pushes only the findings of that impact; the others aren’t counted.

Response: {"pushed": N, "skipped": S, "failed": M, "errors": ["id: msg", ...]}.


Memory

See Memory System for the full architecture.

POST /api/memory/search

Semantic search across indexed memory files using cosine similarity.

Request:

{
  "query": "how does the scheduler work",
  "top_k": 5,
  "dir_path": "/home/user/projects/my-project",
  "channel_id": "abc123"
}
FieldTypeRequiredDescription
querystringyesNatural language search query
top_kintnoNumber of results (default: 5)
dir_pathstringno*Project directory for scoping
channel_idstringno*Alternative to dir_path (looked up from DB)

* At least one of dir_path or channel_id is required.

Response (200):

{
  "results": [
    {
      "file_path": "/home/user/memory/architecture.md",
      "content": "## Scheduler\nThe scheduler runs...",
      "score": 0.87,
      "chunk_index": 1
    }
  ]
}

Errors: 400 if query is empty or neither dir_path nor channel_id provided. 501 if memory indexer not configured.


POST /api/memory/index

Force re-index all memory files for a project directory.

Request:

{
  "dir_path": "/home/user/projects/my-project",
  "channel_id": "abc123"
}
FieldTypeRequiredDescription
dir_pathstringno*Project directory containing memory files
channel_idstringno*Alternative to dir_path

Response (200):

{"count": 3}

The count is the number of files that were (re-)indexed.

Errors: 400 if neither dir_path nor channel_id provided. 501 if memory indexer not configured.


GET /api/memory/files

List distinct indexed memory file paths for a project.

Query Parameters:

ParamTypeRequiredDescription
dir_pathstringno*Project directory
channel_idstringno*Alternative to dir_path

Response (200):

{
  "files": [
    {"file_path": "/home/user/memory/notes.md", "dir_path": "/home/user/projects/my-project"}
  ]
}

Behavior notes: Only returns files that still exist on disk (os.Stat check).

Errors: 400 if resolution fails. 501 if store not configured.


GET /api/memory/file

Read a memory file’s raw content.

Query Parameters:

ParamTypeRequiredDescription
pathstringyesAbsolute path to a .md file

Response (200): Content-Type: text/plain; charset=utf-8 with file contents.

Errors: 400 if path is empty, not absolute, or not a .md file. 404 if file not found.


Readme

GET /api/readme

Get the Loop project README content.

Response (200): Content-Type: text/plain; charset=utf-8 with the compiled-in README text.


UI Bridge

Drives the desktop app: each app window connects to /api/ws/ui, reports what it shows, and runs the steps a command sends it. The CLI wraps it as loop ui:run and loop ui:state; agents use the ui_state and ui_run MCP tools.

For an overview, the rules a command follows and the security model, see UI Bridge .

GET /api/ui/state

The connected windows and what each shows.

Query Parameters:

ParamTypeRequiredDescription
afterintegernoWaits (up to 30s) for a version past this one, then answers with the state as it is

Response:

{
  "version": 12,
  "clients": [
    {
      "client_id": "w-1",
      "focused": true,
      "connected_at": "2026-10-09T10:00:00Z",
      "focused_at": "2026-10-09T10:01:00Z",
      "state": {
        "focused": true,
        "channel_id": "ch-1",
        "tab": "Default",
        "tabs": ["Default", "Agents"],
        "canvas": false,
        "panes": [
          {"id": "chat", "panel": "chat"},
          {"id": "docker-agent-0", "panel": "docker-agent", "open_mode": "fresh", "status": "running", "busy": true},
          {"id": "plugin-1", "panel": "plugin", "plugin": "cogtest/quiz"}
        ],
        "maximized": "docker-agent-0",
        "side": "learn"
      }
    }
  ]
}

plugin is the panel a plugin pane shows, as plugin/panel. busy is an agent terminal that printed in the last 1.5s, e.g. the Claude TUI at work. status is connecting, running, completed or failed. side is the side view open beside the chat (learn, explain or shell), absent when none is.

Errors: 400 invalid after.


POST /api/ui/commands

Runs steps in a window, in order, until one fails.

Request:

{
  "client_id": "w-1",
  "timeout": "2m",
  "steps": [
    {"op": "select_channel", "channel_id": "ch-1"},
    {"op": "add_pane", "panel": "docker-agent", "open_mode": "fresh"},
    {"op": "send_input", "pane": "docker-agent", "text": "run the tests", "submit": true},
    {"op": "wait_for", "pane": "docker-agent", "match": "PASS|FAIL", "lines": 200}
  ]
}

client_id picks the window; without it, the one the user last focused. timeout is a Go duration (default 1m, at most 10m).

Steps: a pane is a pane id or a panel type (its first pane).

OpFieldsDoes
select_channelchannel_idOpens the channel
set_tabtabSwitches the layout tab
create_tabtab?, canvas?Opens a new, empty split tab named tab (default the next Layout N), or with canvas a canvas tab (default the next Canvas N); the result has its tab
rename_tabtab, nameRenames the tab; the result has its new tab
remove_tabtabRemoves the tab, closing its terminals if it’s the open one; the last tab stays
replace_panepane, panel, open_mode?, item?, scope?Puts a new pane in the pane’s place
add_panepanel, next_to?, direction?, side?, open_mode?, item?, scope?Adds a pane beside next_to, or, without it, along that edge of the tab. direction is horizontal (a column, the default) or vertical (a row); side is after (right or below, the default) or before (left or above). Next to a pane in a row of columns (or a column of rows), the pane joins it with an equal share instead of splitting that pane in two
remove_panepaneCloses the pane
maximize_panepaneMakes the pane fill the tab
restore_panePuts a maximized pane back
open_sideviewOpens the side view beside the chat: learn, explain or shell. The result’s pane is side. shell fails while the chat’s container isn’t running
close_sideCloses the side view
open_filepath, line?Opens a file of the channel’s roots in the editor
show_messagemessage_idScrolls the channel’s chat to the message (its #id) and highlights it, paging back through older messages and switching to a tab with a chat pane when needed. Fails when the chat has no such message. A message link, loop://channel/<channel-id>/<message-id>, is select_channel to the channel, then show_message
send_inputpane, text, submit?Types into an agent terminal or the Shell view, or queues the text as a chat pane’s or the Learn view’s learn thread’s next message
read_outputpane, lines?The terminal’s last lines (default 50, at most 2000), or a chat pane’s, its messages as author: text. On the side view: the Shell view’s terminal, the Learn view’s proposals and learn thread, or the Explain view’s explanations
wait_forpane, match?, quiet_ms?, lines?Waits until the last lines match the regular expression match (multiline, RE2 syntax , so no lookarounds or backreferences), or, without it, until the terminal is quiet for quiet_ms (default 2000). After a send_input to that pane in the same command, only output since counts. A session that ended counts as done. On a chat pane: after a send_input to it, until it replied and has nothing left to run (or, with match, until its replies since match); without one, until it’s idle for quiet_ms. On the Explain view: until it matches, or no explanation is pending for quiet_ms

On a canvas tab the panes are its tiles, and canvas in the state is true. add_pane puts the tile beside next_to’s tile, or beside all the tiles, on the side direction and side give, nudged off the others; replace_pane keeps the tile’s place and size; maximize_pane makes the tile fill the canvas’s view.

open_mode is for docker-agent (Chat Terminal) panes: resume, fork (the default) or fresh; and for chat-side panes, Chat (Resume with fork) and Chat (Fresh session): fork (the default) or fresh, resume being the Chat pane’s. A chat pane asked for with fork or fresh opens as a chat-side pane. item names the playground of a playground pane, and scope (global or project) picks it when both scopes have one by that name. For a plugin pane, item names the plugin panel as plugin/panel, or as the plugin’s id when it has one panel; it must be a panel of an installed plugin the project enables.

Terminal steps (send_input, read_output, wait_for) work only on docker-agent and docker-shell panes, which run in containers, and on chat and chat-side panes, and on the side view (pane: "side", after an open_side). A host shell is refused, by the daemon and again by the window. The Shell view is a shell in the chat’s own container, not the host’s. The Explain view takes no input. A pane or next_to of $n is the pane step n (from 1) of the same command made, so a command can add a pane and type into it: [{"op":"add_pane","panel":"chat-side","open_mode":"fresh"},{"op":"send_input","pane":"$1","text":"hi"}]. For a terminal step, step n must be an add_pane or replace_pane of one of those panels, or an open_side. On a chat pane, send_input refuses submit: false, empty text, and a chat waiting on an answer to a question, plan or approval card.

Response:

{
  "client_id": "w-1",
  "results": [
    {"op": "select_channel", "ok": true},
    {"op": "add_pane", "ok": true, "pane": "docker-agent-1"},
    {"op": "send_input", "ok": true, "pane": "docker-agent-1"},
    {"op": "wait_for", "ok": true, "pane": "docker-agent-1", "output": "...\nPASS"}
  ]
}

A failed step has "ok": false and an error; the steps after it don’t run.

A command stays in one channel: the one its last select_channel opened, or else the one the window showed at its first step. If the window shows another channel while the command runs, e.g. because the user opened one, the next step (or the step waiting) fails rather than run there.

send_input refuses text for a docker-agent pane that contains a bracketed-paste marker (ESC [200~ or ESC [201~), which would end the paste early.

Errors: 400 empty steps, a step without an op, a bad timeout, or a terminal step on a host shell; 403 an agent’s command outside its project; 404 no window, or no window client_id; 502 the window dropped; 504 the window didn’t answer in time.


WebSocket Endpoints

GET /api/ws

Real-time events WebSocket. See Events System for the full protocol.

Errors: 501 if events hub is not configured.


GET /api/ws/terminal

Interactive terminal WebSocket. See Terminal WebSocket for the full protocol.

Errors: 501 if terminal manager is not configured.


POST /api/terminal/claims

Claims the terminal sessions an app window holds, on tabs it isn’t showing too. The app sends it once a minute; a session no client is attached to and no window has claimed for 5 minutes is closed. See Session Lifecycle .

Request body:

{
  "session_ids": ["a1b2c3d4", "e5f6a7b8"]
}

Response: 204 No Content.

Errors: 400 if the body is invalid.


GET /api/ws/ui

The app windows’ connection to the UI bridge : a window sends hello, then state whenever what it shows changes, and answers each command with a result message holding its results. Owner only.


Browser

POST /api/browser/action

Unified endpoint for all browser operations. Used by both the loop-browser MCP server (inside agent containers) and the desktop browser panel frontend.

Request:

{
  "channel_id": "ch-abc123",
  "action": "navigate",
  "params": {"url": "https://example.com"}
}

Actions:

ActionParamsDescription
navigateurlNavigate to a URL
reload—Reload the current page
go_back—Navigate back in history
go_forward—Navigate forward in history
get_page_info—Get current URL and title
get_element_refs—Get the page’s interactive elements
mouse_clickx, y, button, click_countClick at coordinates
mouse_movex, yMove mouse
mouse_scrollx, y, delta_x, delta_yScroll
mouse_downx, y, buttonMouse button down
mouse_upx, y, buttonMouse button up
key_presskeyPress a key
type_texttextType text
click_refrefs, ref_indexClick element by ref
screenshot—Capture screenshot
evaluate_jsexpressionEvaluate JavaScript
list_tabs—List all open tabs
new_taburlOpen a new tab
switch_tabtarget_idMake a tab the active one and bring it to the front
close_tabtarget_idClose a tab
resize_windowwidth, heightResize viewport
scroll_into_viewbackend_node_idScroll element into view
read_consolepattern, only_errors, clear, limitRead console messages
read_networkpattern, clear, limitRead network requests

Responses:

CodeDescription
200JSON response with result, error, image, element_refs, tabs, page_info, or screenshot_path
400Missing channel_id or invalid JSON
503Browser not configured (browser_enabled: false)

The endpoint handles Chrome lifecycle internally: lazily starts Chrome on first action, touches the idle timer on every action, and manages CDP connections.


GET /api/ws/browser

WebSocket endpoint for browser screencast streaming and input.

Returns 503 if browser is not configured. The WS handles four message types:

MessageDirectionDescription
startClient → ServerInitialize CDP connection and screencast for a channel
stopClient → ServerStop the browser session
screencastClient → ServerStart screencast frame streaming (with width/height)
inputClient → ServerMouse/keyboard input events
Binary framesServer → ClientJPEG screencast frames
startedServer → ClientCDP connected, ready
stoppedServer → ClientSession stopped
tabsServer → ClientTab list update
tab_switchedServer → ClientActive tab changed
tab_createdServer → ClientNew tab opened
tab_closedServer → ClientTab closed
errorServer → ClientError message

Control operations (navigate, tabs, reload, etc.) go through POST /api/browser/action, not the WebSocket.


Containers

GET /api/containers

List all tracked containers across all channels. Returns containers sorted with running containers first (newest first), then non-running containers (newest first).

Response: 200 OK

[
  {
    "container_id": "abc123def456",
    "channel_id": "chan_a",
    "type": "agent",
    "status": "running",
    "container_name": "loop-my-project-a1b2c3",
    "created_at": "2026-03-31T10:00:00Z",
    "updated_at": "2026-03-31T10:00:00Z"
  },
  {
    "container_id": "def789ghi012",
    "channel_id": "chan_b",
    "type": "chrome",
    "status": "pending-removal",
    "container_name": "loop-chrome-chan-b",
    "created_at": "2026-03-31T09:50:00Z",
    "updated_at": "2026-03-31T10:01:00Z",
    "remove_at": "2026-03-31T10:06:00Z"
  }
]
FieldTypeDescription
container_idstringDocker container ID
channel_idstringChannel the container belongs to
typestring"agent", "shell", or "chrome"
statusstring"running", "stopped", or "pending-removal"
container_namestringDocker container name
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last status change timestamp
remove_atstring?ISO 8601 scheduled removal time (only for pending-removal)

Errors: 503 if the container registry is not configured.


Plugins

Owner only, except the run and tool routes below. Every route answers 501 while plugins.enabled is off in the global config.

GET /api/plugins

List the installed plugins.

Response: 200 OK

{
  "plugins": [
    {
      "id": "echo",
      "name": "Echo",
      "image": "ghcr.io/acme/loop-plugin-echo@sha256:…",
      "digest": "sha256:…",
      "version": "1.0.0",
      "api": "1",
      "capabilities": ["ui.panel", "ui.notification.card"],
      "dev": false,
      "status": "installed",
      "installed_at": "2026-10-10T09:00:00Z",
      "updated_at": "2026-10-10T09:00:00Z",
      "panels": [{"id": "main", "label": "Echo", "entry": "ui/main/index.html", "singleton": true}],
      "cards": [{"id": "score", "entry": "ui/score.html"}]
    }
  ]
}

capabilities are the ones the owner approved. digest is empty for a dev install. name, panels and cards come from the manifest of a plugin that may run; panels only when the owner approved ui.panel, cards only for ui.notification.card. settings_schema is the manifest’s settings, and global_settings the global config’s plugins.<id>.settings. With channel_id, project_settings are that channel’s project config’s own values, settings what the plugin runs with there (each default, then the global, then the project values) and settings_problems the values left out for no such setting or a value the schema refuses. default_schedules are the manifest’s default_schedule of each action that has one, when the owner approved schedule, and global_schedules the global config’s plugins.<id>.schedules. With channel_id, project_schedules are the project config’s own and schedules the ones that apply there: the global ones, each replaced by the trusted project one. schedule_problems are the entries that make no task, for no such action or a schedule Loop refuses: the global ones, with channel_id each replaced by the project’s own, trusted or not.

POST /api/plugins/inspect

Pull an image, extract and validate its /loop-plugin dir, and return what it asks for. Installs nothing.

Request: {"image": "ghcr.io/acme/loop-plugin-echo:1.0.0", "dev": false}. With dev, a local image is used as is, unpulled and unpinned.

Response: 200 OK

{
  "id": "echo",
  "name": "Echo",
  "version": "1.1.0",
  "api": "1",
  "image": "ghcr.io/acme/loop-plugin-echo@sha256:…",
  "digest": "sha256:…",
  "dev": false,
  "capabilities": ["ui.panel", "files.read"],
  "new_capabilities": ["files.read"],
  "previous_version": "1.0.0"
}

new_capabilities are the ones the installed version wasn’t approved for (all of them on a first install). previous_version is set when the plugin is already installed.

Errors: 400 without an image; 422 if the pull fails or the image isn’t a valid plugin.

POST /api/plugins

Install, or upgrade to, an image. Upgrading stops the plugin’s containers; they start on the new image when next used.

Request:

{"image": "ghcr.io/acme/loop-plugin-echo:1.1.0", "digest": "sha256:…", "approve": ["ui.panel", "files.read"]}

approve must list every capability the plugin asks for. digest, from the inspect the owner reviewed, refuses the install if the image changed since. dev works as for inspect.

Response: 200 OK, the same body as inspect.

Errors: 400 without an image or with a capability not approved; 409 if the image’s digest isn’t digest; 422 as for inspect.

DELETE /api/plugins/{id}

Remove a plugin: its containers in every channel, its extracted files and its record. ?purge=true also removes its data dirs, its gate audit logs and the hidden threads its agent runs went in, with their messages and sessions.

Response: 204 No Content

Errors: 404 if the plugin isn’t installed.

POST /api/plugins/{plugin}/actions/{action}

Run a plugin action by hand in a channel, thread or worktree thread. The plugin’s container for it starts first if it isn’t running. The plugin answers once it has taken the action, so the run itself goes on after the response.

Request body:

{"channel_id": "ch-1"}

Response: 202 Accepted

{"channel_id": "ch-1"}

channel_id is the channel the action ran in.

Errors: 400 without channel_id; 404 if the channel or plugin doesn’t exist, or the plugin has no such action; 409 if the plugin isn’t runnable; 502 if its container doesn’t start or the plugin refuses the action; 503 while the plugin isn’t answering (see Plugins ).

POST /api/plugin/v1/runs

Called by a plugin, with the token in its LOOP_PLUGIN_TOKEN_FILE, to start a run of one of its agents in the channel its container serves. The run goes in the channel’s hidden plugin:<id> thread, with a fresh session each time, and gets the agent’s tools as plugin_<tool> MCP tools. A read-only agent runs read-only. The plugin must have been approved for agent.readonly, or agent.write for an agent that writes.

Request body:

{"agent": "say", "prompt": "Call plugin_echo with the text hi.", "model": "", "effort": ""}

model and effort are optional and fall back to the channel’s.

Response: 202 Accepted

{"run_id": "ask-…", "thread_id": "…"}

Loop then sends plugin.run_started to the channel.

Errors: 400 without agent or prompt; 403 for any caller but a plugin, or without the capability; 404 if the agent or the channel doesn’t exist; 409 if the plugin isn’t runnable.

GET /api/plugins/{plugin}/agent?channel_id=

The plugin’s agent runs in the channel, thread or worktree thread, for the agent chat beside its panels.

Response: 200 OK

{"thread_id": "plugin:cogtest-a1b2c3", "running": true}

thread_id is the hidden thread the plugin’s runs go in, omitted until the plugin starts one there. running is whether one runs now.

Errors: 400 without channel_id; 404 if the channel doesn’t exist.

POST /api/plugins/{plugin}/tools/{tool}

Called by a plugin agent’s MCP server when the agent calls one of the plugin’s tools. Only a run of that plugin’s own agent, in its plugin:<id> thread, may call it. Loop checks the tool is the agent’s and the input against the tool’s input_schema, then sends {"run_id", "agent", "thread_id", "input"} to the plugin’s POST /tools/{tool}.

Request body:

{"input": {"text": "hi"}}

Response: 200 OK

{"content": "hi", "is_error": false}

An unknown tool, an input the schema rejects, or a plugin that isn’t answering comes back as is_error: true, so the agent sees why.

Errors: 403 for any caller but the plugin’s own agent run; 404 if the thread or its channel is gone; 502 if the container doesn’t start or the plugin fails the call.

POST /api/plugins/{plugin}/notifications

A notification from one of the plugin’s panel or card pages (the bridge’s ui.notification.notify), which the app sends on its behalf. It’s kept as a notify notification in the channel’s drawer, like one from the plugin’s container, and shares its limit.

Request body:

{"channel_id": "ch-1", "title": "Quiz ready", "body": "3 questions"}

Response: 204 No Content

Errors: 400 without channel_id or title; 403 if the plugin isn’t enabled in the channel or wasn’t approved for ui.notification.notify; 404 if the channel doesn’t exist; 429 with Retry-After past five a minute.

GET /api/channels/{id}/composer-switches

The composer switches of the plugins the chat’s project enables, in plugin order, with their state in the chat (a channel or a listed thread):

[{"plugin_id": "demo", "switch_id": "mode", "label": "Mode", "icon": "data:image/svg+xml;base64,…", "default_title": "Mode is off", "on": true, "status": "ok", "title": "Mode is on"}]

icon is the plugin’s SVG as a data URL, default_title the manifest’s tooltip for when the plugin set no title. The app follows the state with plugin.composer_switch events, and lists them again on plugins.changed. With plugins off the list is empty.

Errors: 404 if the chat doesn’t exist or is a side chat or hidden thread.

PUT /api/channels/{id}/composer-switches/{plugin}/{switch}

The owner clicked a switch: Loop calls the plugin’s POST /switches/{switch} on the container of the chat’s channel, with the thread’s id for a thread, keeps the state it answers, broadcasts it as plugin.composer_switch and returns it.

Request body: {"on": true}

Response: {"on": true, "status": "busy", "title": "Connecting…"}

Errors: 404 if the chat, plugin or switch doesn’t exist; 403 if the plugin isn’t approved for ui.composer_switch or enabled in the chat’s project; 409 if it isn’t runnable; 502 if the call fails or the plugin answers an invalid state; 501 when plugins are off.

ANY /api/plugins/{plugin}/x/{path...}?channel_id=

The plugin’s own API for its panels. Loop sends the method, the query (without channel_id), the body (up to 8 MB) and its Content-Type to the plugin’s /x/{path} in the container of the channel, thread or worktree thread, starting it if needed, and answers with the plugin’s status, body and Content-Type.

Errors: 400 without channel_id; 404 if the channel or plugin doesn’t exist; 409 if the plugin isn’t runnable; 413 for a larger body; 502 if the container doesn’t start or the call fails; 503 while the plugin isn’t answering.

Plugin API v1

Called by a plugin’s container with the token in its LOOP_PLUGIN_TOKEN_FILE; that token can call nothing else, and it’s held to the channel the container serves. Every route answers 403 for any other caller or without the capability the route needs, 409 if the plugin isn’t runnable and 404 if its channel is gone.

RouteCapabilityAnswer
GET /api/plugin/v1/channel{"id", "name", "dir_path", "platform", "model", "effort", "settings"}; settings are the plugin’s settings in the channel’s project
GET /api/plugin/v1/channel/files?path=&start=&end=files.read{"path", "content", "start", "end", "total_lines"}; lines 1-based and inclusive, both optional; .git/ refused, and files agentgate denies the channel’s agents (403); 404 if missing, 400 for a directory or a range past the end, 413 over the size limit
GET /api/plugin/v1/channel/tree?glob=files.read{"files", "truncated"?}: the files git tracks, or every file outside .git/ without git, less those agentgate denies the channel’s agents; the glob matches the path or its base name; at most 50,000
GET /api/plugin/v1/channel/git/recent?days=git.read{"commits": [{"sha", "subject", "author", "time"}], "files": [{"path", "commits"}]}; days defaults to 14, at most 365; the last 500 non-merge commits; 409 without git
GET /api/plugin/v1/channel/git/headgit.read{"sha", "subject", "author", "time", "branch"} for HEAD; branch is empty when it’s detached; 409 without git or without commits
GET /api/plugin/v1/runs/{run_id}agent.readonly or agent.write{"run_id", "thread_id", "status"} with status queued or running, for the plugin’s run in the channel; 404 once it ended
DELETE /api/plugin/v1/runs/{run_id}agent.readonly or agent.writecancels the run if it’s running, or deletes its queued message → 204; the plugin’s /runs/{run_id}/done then gets failed. 404 once it ended, 409 while it’s being claimed from the queue
GET /api/plugin/v1/channel/quality/hotspots?limit=&offset=quality.readas GET /api/channels/{id}/quality/complexity; 503 before the first scan
POST /api/plugin/v1/eventsbody {"type", "data"} (up to 64 KB) → 204; broadcast as a plugin.event event with {"plugin_id", "type", "data"} for the channel
POST /api/plugin/v1/notifyui.notification.notifybody {"title", "body"} → 204; kept as a notify notification ; there’s no desktop notification. At most five a minute per plugin, cards included: past that 429 with Retry-After in seconds
PUT /api/plugin/v1/memory/{name}memory.writebody {"content"} (up to 256 KB) → 204; writes plugin-<plugin>-<name>.md in ~/.claude/projects/<encoded dir>/memory/ for the channel’s dir, which search_memory indexes. name matches ^[a-z0-9][a-z0-9-]{0,63}$ (400 otherwise); 409 when the channel has no dir
DELETE /api/plugin/v1/memory/{name}memory.writeremoves that file → 204, also when it’s already gone
PUT /api/plugin/v1/badgeui.badgebody {"text", "title"?} → 204; keeps the plugin’s badge on the channel’s sidebar row (see plugin_badges in GET /api/channels ) and broadcasts a plugin.badge event with {"plugin_id", "text", "title"?} to every window. text is trimmed and must be 1 to 16 characters on one line, title at most 200 (400 otherwise)
DELETE /api/plugin/v1/badgeui.badgeremoves the plugin’s badge → 204, also when there was none; broadcasts plugin.badge with an empty text
POST /api/plugin/v1/cardsui.notification.cardbody {"card", "title"?, "data"?} (up to 64 KB) → 201 {"id"}; keeps a card notification for the channel with data and broadcasts notifications.changed. title is cut to one line of 120 characters. Shares notify’s limit (429). 400 for a card the manifest doesn’t declare (ui.cards) and on Slack or Discord
POST /api/plugin/v1/ui/commandsui.runas POST /api/ui/commands , held to the channel: the first step is select_channel, and each select_channel names the channel or one of its threads that isn’t hidden (403 otherwise)
PUT /api/plugin/v1/composer/switches/{id}ui.composer_switchbody {"on", "status"?, "title"?, "thread_id"?} → 204; keeps what one of the manifest’s switches (ui.composer_switches) shows in the composer of the channel, or of its listed thread thread_id, and broadcasts a plugin.composer_switch event for that chat with {"plugin_id", "switch_id", "on", "status"?, "title"?}. status is busy, ok, error or empty, title at most 200 characters (400 otherwise); 404 for a switch the manifest doesn’t declare or a thread that isn’t the channel’s
DELETE /api/plugin/v1/composer/switches/{id}?thread_id=ui.composer_switchforgets the switch’s state there → 204, so it shows off; broadcasts plugin.composer_switch
POST /api/plugin/v1/channel/live/controllive.controlbody {"request": {"subtype", …}, "thread_id"?} → 200 {"response"}; sends request to the live session of the channel or its listed thread as a control request and answers with its response. 400 without a subtype or for one the manifest’s live.control doesn’t list; 409 without a live session; 502 with the session’s error when it refuses; 503 while live sessions are unavailable; 504 after 30 s
PUT /api/plugin/v1/channel/live/hold?thread_id=live.control204; starts the chat’s live session if needed and keeps it from idling out while the plugin holds it. During an agent image build it answers before the session runs; the session’s live.status event tells when it does. 409 when the chat’s turns don’t run live. Loop lets go of the hold once the plugin may no longer control the chat
DELETE /api/plugin/v1/channel/live/hold?thread_id=live.control204; lets the session idle out again
POST /api/plugin/v1/ui/open_fileui.open_filebody {"path", "line"?}; runs select_channel for the channel, then the open_file step, and answers as POST /api/ui/commands. 400 for an empty, absolute or .. path, or a negative line

Notifications

What Loop and the plugins tell the owner about a channel or thread, listed in the desktop app’s notification drawer of that channel. Owner only; every route answers 501 when the store isn’t configured. Each change broadcasts a global notifications.changed event with no data; the app refetches the counts and the open drawer’s list.

Loop keeps a notification for a run the owner started that failed (not one the owner stopped, a scheduled one, learn, explain or a plugin’s agent) and for a scheduled plugin action that failed (in the task’s thread, if it has one). Loop’s own scheduled tasks and workflows make none; their threads and run history show how they went. A plugin adds one with POST /api/plugin/v1/notify or POST /api/plugin/v1/cards . Loop’s own and each plugin’s are capped at the newest 200. Deleting a channel drops its notifications; removing a plugin drops the plugin’s.

GET /api/notifications?channel_id=

The channel’s newest 200, newest first, and how many of them are unread. channel_id is required (400); a thread’s are its own, not its parent’s.

{
  "notifications": [
    {"id": 12, "plugin_id": "quiz", "kind": "card", "channel_id": "4bc8…", "title": "Quiz results", "card": "score", "data": {"score": 3}, "read": false, "created_at": "2026-10-12T09:00:00Z"},
    {"id": 11, "kind": "run", "channel_id": "4bc8…", "title": "Scheduled run finished", "body": "in 42s", "read": true, "created_at": "2026-10-12T08:00:00Z"}
  ],
  "unread": 1
}

kind is run, workflow, notify or card. plugin_id is omitted for Loop’s own; card and data come with a card only.

GET /api/notifications/unread

How many unread notifications each channel or thread has, for the sidebar; the ones with none are left out.

{"unread": {"4bc8…": 2, "038852d2f1ae": 1}}

POST /api/notifications/read

Body {"channel_id": "4bc8…", "up_to": 12}: marks the channel’s notifications up to that id read → 204. channel_id is required (400).

DELETE /api/notifications/{id}

Dismisses one → 204; 404 if there’s no such notification, 400 for an id that isn’t a number.

DELETE /api/notifications?channel_id=

Dismisses the channel’s → 204. channel_id is required (400).

Agent Registry

GET /api/agents

List a channel’s agents: its chats first, then the terminal agents registered in it. The chats are the channel’s own, chat, and each of its side chats, by its channel ID (side-<id>); a chat’s status is running while its live session takes a turn, idle otherwise. Asked for a side chat, it lists its parent channel’s agents. self marks the chat the request names, since every chat runs as agent chat.

Query Parameters:

ParamRequiredDescription
channel_idYesChannel ID to list agents for

Response: JSON array of AgentInfo objects, a chat’s with self when it’s the one channel_id names.

[
  {
    "agent_id": "chat",
    "channel_id": "ch-1",
    "session_id": "",
    "name": "Chat",
    "status": "idle",
    "work_summary": "",
    "self": true,
    "created_at": "0001-01-01T00:00:00Z",
    "updated_at": "0001-01-01T00:00:00Z"
  },
  {
    "agent_id": "docker-agent-0",
    "channel_id": "ch-1",
    "session_id": "sid-abc",
    "name": "Worker",
    "status": "running",
    "work_summary": "implementing auth module",
    "created_at": "2026-03-25T10:00:00Z",
    "updated_at": "2026-03-25T10:05:00Z"
  }
]
StatusDescription
200JSON array (empty [] if no agents)
400Missing channel_id
500Channel or side chat lookup failed
503Agent registry not configured

PATCH /api/agents/{id}

Update an agent’s status, name, or work summary.

Request Body:

{
  "channel_id": "ch-1",
  "status": "running",
  "work_summary": "indexing files",
  "name": "Worker"
}

All fields except channel_id are optional — only non-empty values are applied.

StatusDescription
200Updated AgentInfo JSON
400Missing channel_id or invalid JSON
404Agent not found
503Agent registry not configured

DELETE /api/agents/{id}

Unregister an agent from the registry. Called by the MCP server on graceful shutdown.

Path Parameters:

ParamTypeDescription
idstringAgent ID (e.g. "docker-agent-0")

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel ID the agent belongs to

Response: 204 No Content

Behavior notes: Broadcasts an agent_instance.unregistered event to the frontend via the EventsHub.

Errors: 400 if agent_id or channel_id is missing. 503 if agent registry not configured.


POST /api/agents/{id}/message

Send a message to an agent as [from <from_agent_id>] <content>. A terminal agent gets it typed into its terminal in one bracketed paste followed by Enter; Claude Code queues it when the agent is busy. A chat (chat, or a side chat’s side-<id>) gets it queued as its next message, as POST /api/messages would. channel_id is the sender’s channel: a side chat’s names its parent’s agents, and its from_agent_id chat is relabelled side-<id>. from_agent_id is optional; when set it must be chat or an agent registered in the channel.

Request Body:

{
  "channel_id": "ch-1",
  "from_agent_id": "docker-agent-0",
  "content": "I finished the API, you can start tests"
}
StatusDescription
204Message typed into the agent’s terminal, or queued in the chat
400Missing channel_id or content, from_agent_id isn’t an agent of the channel, a chat messaging itself, or content holds a bracketed-paste marker (terminal agents)
404Target agent not found
409Target agent has no terminal, or its terminal session is gone; messages not configured for a chat
500Channel or side chat lookup failed
503Agent registry not configured

Configuration

Config endpoints expose a schema-driven API for reading and writing Loop configuration. Both global (~/.loop/config.json) and per-project ({workDir}/.loop/config.json) configs are supported. The schema endpoint powers the Settings form UI.

GET /api/config/schema

Returns the JSON Schema describing all config fields, their types, defaults, and descriptions. Used by the frontend to render typed form controls.

Response (200):

{
  "type": "object",
  "properties": {
    "platforms": {
      "type": "array",
      "items": {"type": "string", "enum": ["local", "discord", "slack"]},
      "description": "Platforms to enable"
    },
    "claude_model": {
      "type": "string",
      "enum": ["", "claude-opus-4-6", "claude-sonnet-4-6"],
      "description": "Claude model to use"
    }
  }
}

The schema includes metadata for rendering (e.g. enum for dropdowns, format: "password" for secret fields).


GET /api/config

Returns the global config as both parsed JSON and raw HJSON text.

Response (200):

{
  "config": { "platforms": ["local"], "claude_model": "" },
  "raw": "{\n  \"platforms\": [\"local\"]\n}"
}
FieldTypeDescription
configobjectParsed config values
rawstringRaw HJSON file contents (for the JSON editor view)

PUT /api/config

Save global config. Accepts raw HJSON text.

Request:

{
  "raw": "{\n  \"platforms\": [\"local\"],\n  \"claude_model\": \"claude-opus-4-6\"\n}"
}
FieldTypeRequiredDescription
rawstringyesHJSON config text to write to ~/.loop/config.json

Response (200):

{"ok": true}

Errors: 400 if the HJSON is invalid.


GET /api/config/project

Returns the project config for a channel.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel ID to look up the project directory

Response (200):

{
  "config": { "claude_model": "claude-opus-4-6" },
  "raw": "{\n  \"claude_model\": \"claude-opus-4-6\"\n}"
}

Same shape as GET /api/config. If no project config file exists, config is an empty object and raw is "".

Errors: 400 if channel_id is missing. 404 if channel not found.


GET /api/shortcuts

Returns prompt shortcuts with resolved prompt text. When a channel_id is provided, project-level shortcuts are merged on top of global ones (project overrides global by name).

Query Parameters:

ParamTypeRequiredDescription
channel_idstringnoChannel ID to merge project-level shortcuts

Response (200):

[
  {
    "name": "coverage",
    "description": "Run coverage check",
    "prompt": "Run make coverage-check and report results"
  }
]
FieldTypeDescription
namestringShortcut identifier
descriptionstringHuman-readable description
promptstringResolved prompt text (inline or loaded from file)

Shortcuts with unresolvable prompts (e.g. missing file) are silently skipped.


POST /api/shortcuts

Add, update, or delete a prompt shortcut in the global or project config file.

Request Body:

FieldTypeRequiredDescription
actionstringyesadd, update, or delete
namestringyesShortcut name
scopestringnoglobal (default) or project
channel_idstringconditionalRequired when scope is project
descriptionstringnoHuman-readable description (add/update)
promptstringconditionalInline prompt text (required for add/update unless prompt_path is set)
prompt_pathstringconditionalPath to prompt file relative to shortcuts/ dir (mutually exclusive with prompt)

Response: 204 No Content on success.

Errors:

  • 400 — missing name, invalid action, missing prompt, mutually exclusive fields, missing channel_id for project scope, or a project .loop or .loop/config.json that is a symlink outside the project
  • 404 — shortcut not found (update/delete)
  • 409 — duplicate name (add)

GET /api/bash-shortcuts

Returns bash shortcuts with resolved command text. When a channel_id is provided, project-level shortcuts are merged on top of global ones (project overrides global by name).

Query Parameters:

ParamTypeRequiredDescription
channel_idstringnoChannel ID to merge project-level shortcuts

Response (200):

[
  {
    "name": "make lint",
    "description": "Run the linter",
    "command": "make lint"
  }
]
FieldTypeDescription
namestringShortcut identifier
descriptionstringHuman-readable description
commandstringResolved command text (inline or loaded from file)

Shortcuts with unresolvable commands (e.g. missing file) are silently skipped.


POST /api/bash-shortcuts

Add, update, or delete a bash shortcut in the global or project config file.

Request Body:

FieldTypeRequiredDescription
actionstringyesadd, update, or delete
namestringyesShortcut name
scopestringnoglobal (default) or project
channel_idstringconditionalRequired when scope is project
descriptionstringnoHuman-readable description (add/update)
commandstringconditionalInline command text (required for add/update unless command_path is set)
command_pathstringconditionalPath to script file relative to bash-shortcuts/ dir (mutually exclusive with command)

Response: 204 No Content on success.

Errors:

  • 400 — missing name, invalid action, missing command, mutually exclusive fields, missing channel_id for project scope, or a project .loop or .loop/config.json that is a symlink outside the project
  • 404 — shortcut not found (update/delete)
  • 409 — duplicate name (add)

PUT /api/config/project

Save project config for a channel. Owner-only.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel ID

Request:

{
  "content": "{\n  \"claude_model\": \"claude-opus-4-6\"\n}"
}

Response: 204 No Content

Creates the .loop/ directory and config file if they don’t exist. A project config that was trusted stays trusted (see GET /api/config/project/trust ).

Errors: 400 if channel_id is missing, the channel isn’t found, the HJSON is invalid, or .loop or .loop/config.json is a symlink outside the project.


GET /api/config/project/trust

Whether the project config’s host-reaching fields apply as written (see Configuration: Project Config Trust ). Owner-only.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel ID; worktree channels use their root project’s config

Response (200):

{
  "trusted": false,
  "current": "{\n  \"mounts\": [\n    \"~/data:/data\"\n  ]\n}",
  "approved": "{}",
  "diff": "--- .loop/config.json (last trusted)\n+++ .loop/config.json (now)\n@@ -1 +1,5 @@\n-{}\n+{\n+  \"mounts\": [\n+    \"~/data:/data\"\n+  ]\n+}\n",
  "hash": "4f1c…"
}
FieldDescription
trustedThe fields apply as written
currentThe fields as the file has them now, as indented JSON
approvedThe version last trusted, which applies while trusted is false; "" if never trusted
diffapproved → current as a unified diff, from /dev/null if never trusted; "" while trusted
hashIdentifies current; pass it to POST to trust exactly this version

Errors: 400 if channel_id is missing or the channel isn’t found. 500 if the project config doesn’t parse. 501 if trust isn’t configured.


POST /api/config/project/trust

Trust the project config as reviewed. Owner-only.

Query Parameters: channel_id as above.

Request:

{"hash": "4f1c…"}

Response: 204 No Content

Errors: 400 if channel_id or hash is missing. 409 if the config changed since the status with that hash: fetch the status again. 501 if trust isn’t configured.


GET /api/config/history

The global config’s revisions, newest first, or those of another file of ~/.loop. Owner-only.

Loop records a revision of ~/.loop/config.json and of each channel’s project .loop/config.json whenever the content changes: on its own writes (Settings, learn, shortcut, workflow and built-in edits, restores), and within a minute of an edit made outside Loop. Each file keeps its newest 200 revisions.

The files the configs point at are recorded the same way: everything under templates/ (task templates), shortcuts/ (prompt shortcuts), workflows/ (workflow node prompts), review/ (review prompts), bash-shortcuts/ (bash shortcut commands) and components/ (chat component templates) of each .loop dir. Loop doesn’t write them itself, so their revisions come from edits made outside Loop and from restores. Symlinks, files over 1 MiB and files that aren’t UTF-8 text are skipped.

Query Parameters:

ParamTypeRequiredDescription
filestringnoThe file, relative to the .loop dir with / separators, such as templates/heartbeat.md. Default config.json

Response (200):

{
  "path": "/home/user/.loop/config.json",
  "revisions": [
    {"id": 12, "source": "settings", "created_at": "2026-10-09T10:00:00Z", "added": 2, "removed": 1},
    {"id": 3, "source": "initial", "created_at": "2026-10-01T09:00:00Z", "added": 40, "removed": 0}
  ]
}
FieldDescription
sourceWhat wrote the content: settings, learn, shortcuts, workflows, builtins, restore:<id>, external for an edit made outside Loop, or initial for the content Loop first saw
added, removedLines changed from the revision before it; the oldest revision counts every line as added
missingtrue when the file no longer exists, so even its newest revision can be restored; omitted otherwise

Errors: 400 if file isn’t config.json or a file under one of the dirs above. 500 if the loop directory isn’t configured. 501 if config history isn’t configured.


GET /api/config/history/files

The files of ~/.loop with recorded revisions, by path. Owner-only. A project’s .loop dir under ~/.loop isn’t included.

Response (200):

{
  "root": "/home/user/.loop",
  "files": [
    {"file": "config.json", "revisions": 12, "updated_at": "2026-10-09T10:00:00Z"},
    {"file": "templates/heartbeat.md", "revisions": 2, "updated_at": "2026-10-08T15:30:00Z"}
  ]
}
FieldDescription
fileRelative to root, with / separators; the file to pass to GET /api/config/history
revisionsHow many revisions are kept
updated_atWhen the newest one was recorded

Errors: 500 if the loop directory isn’t configured. 501 if config history isn’t configured.


GET /api/config/project/history

The project config’s revisions, or those of another file of the project’s .loop dir, as for GET /api/config/history . Owner-only.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel ID; worktree channels use their root project’s config
filestringnoAs for GET /api/config/history . Default config.json

Errors: 400 if channel_id is missing or the channel isn’t found, or for an invalid file. 501 if config history isn’t configured.


GET /api/config/project/history/files

The files of the project’s .loop dir with recorded revisions, as for GET /api/config/history/files . Owner-only.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel ID; worktree channels use their root project’s .loop dir

Errors: 400 if channel_id is missing or the channel isn’t found. 501 if config history isn’t configured.


GET /api/config/history/{id}

One revision with its content and the diff from the revision before it. Owner-only.

Query Parameters:

ParamTypeRequiredDescription
againstintegernoA revision of the same file to diff from instead, older or newer

Response (200):

{
  "id": 12,
  "path": "/home/user/.loop/config.json",
  "content": "{\n  \"claude_model\": \"sonnet\"\n}\n",
  "hash": "9b2e…",
  "source": "settings",
  "created_at": "2026-10-09T10:00:00Z",
  "diff": "--- /home/user/.loop/config.json\n+++ /home/user/.loop/config.json\n@@ …"
}

diff is from /dev/null for the oldest revision, and from the against revision when it’s given.

Errors: 400 for an invalid id or against, or an against revision of another file. 404 if either revision doesn’t exist. 501 if config history isn’t configured.


POST /api/config/history/{id}/restore

Write a revision’s content back to its file. Owner-only. The restore is recorded as a revision with source restore:<id>, unless the content is already the newest revision’s, and a project config that was trusted stays trusted, as with PUT /api/config/project . A file other than the config is created again, with its directory, if it was deleted; restoring one doesn’t change the project config’s trust.

Response: 204 No Content

Errors: 400 for an invalid id, or a revision of a file that’s no longer one of the files above. 404 if there’s no such revision. 500 if the loop directory isn’t configured or the file can’t be written, which includes a project file that a symlink leads outside the project. 501 if config history isn’t configured.


GET /api/backup

The backup’s settings, the newest backup and the last failure. Owner-only. See Backup .

Response (200):

{
  "enabled": true,
  "dir": "/home/user/Dropbox/loop-backups",
  "times_per_day": 2,
  "keep_last": 3,
  "keep_daily": 7,
  "keep_weekly": 4,
  "keep_monthly": 6,
  "recipient": "age1...",
  "running": false,
  "last": {"file": "loop-20261014T080000Z.zip.age", "path": "/home/user/Dropbox/loop-backups/loop-20261014T080000Z.zip.age", "at": "2026-10-14T08:00:00Z", "size": 52428800, "encrypted": true},
  "count": 12,
  "total_size": 629145600,
  "next_at": "2026-10-14T20:00:00Z"
}
FieldDescription
dirThe backup directory, with ~/ expanded; omitted when no path is set
keep_last, keep_daily, keep_weekly, keep_monthlyThe retention tiers; 0 turns a tier off
recipientThe public key new backups are encrypted to; omitted when they aren’t encrypted
runningA backup is being written
lastThe newest backup in dir; omitted when there’s none. encrypted is set for a .zip.age
count, total_sizeHow many backups are in dir, and their size in bytes
next_atWhen the next scheduled backup is due; omitted when backups aren’t enabled
error, error_atWhy the last backup failed and when, until one succeeds; error alone when the settings or the directory can’t be read

Errors: 501 if backups aren’t configured.


POST /api/backup

Start a backup now, whether or not scheduled backups are enabled. Owner-only. Returns 202 at once; GET /api/backup reports running until it’s done.

Errors: 400 if no path is set, it isn’t absolute, or recipient isn’t an age public key. 409 if a backup is already running. 501 if backups aren’t configured.


POST /api/backup/key

Generate a key pair to encrypt backups with, and set its public key as the global config’s backup.recipient, replacing any earlier one. Owner-only. The private key is returned only here: Loop keeps no copy, and encrypted backups can’t be restored without it.

Response (200):

{"recipient": "age1...", "identity": "AGE-SECRET-KEY-1..."}

Errors: 500 if the loop directory isn’t configured, or the key can’t be generated or saved.


POST /api/builtins/restore

Re-seed any missing built-in workflows or prompt shortcuts under ~/.loop/. Idempotent — entries the user has kept (or modified) are left untouched, so this restores deletions rather than resetting to defaults. Backs the “Restore built-ins” bar in the Settings panel.

Request Body:

FieldTypeRequiredDescription
kindstringyes"workflows" or "shortcuts"

Canonical names per kind:

KindNames
workflowsreview-loop, review-fix-loop
shortcutsbuiltin code review, builtin simplify

Response (200):

{
  "kind": "workflows",
  "added": ["review-fix-loop"],
  "skipped": ["review-loop"]
}

added lists names that were missing and have now been written back. skipped lists names that were already present. Empty arrays are emitted as [] (never null).

Errors: 400 if kind is not "workflows" or "shortcuts". 500 on filesystem errors.


Playground

The playground stores named HTML/CSS/JS items and broadcasts updates for live rendering in the desktop app’s Playground panel. Items can be stored globally (~/.loop/playground/{name}/) or per-project (.loop/playground/{name}/ in the channel’s working directory).

All playground endpoints accept optional scope and channel_id query parameters to target project-scoped items. Without these, operations default to global scope.

A playground’s files are opened within its own directory, so a symlink an agent plants there is followed only while it stays inside: one to a host file or another playground gets 400, or 404 on the serve routes. Deleting a link removes the link, never its target. A project’s .loop/playground must be a real directory, not a symlink.

PUT /api/playground?name=...

Update a named playground. Stores files and broadcasts a playground.update event.

Query Parameters:

ParamTypeRequiredDescription
namestringyesPlayground name (alphanumeric, hyphens, underscores, max 64 chars)
scopestringno"global" (default) or "project"
channel_idstringnoRequired when scope=project — identifies the project directory

Request:

{
  "html": "<div id='app'></div>",
  "css": "body { margin: 0; background: #111; }",
  "js": "import confetti from 'canvas-confetti'; confetti();",
  "import_map": "{\"imports\":{\"canvas-confetti\":\"https://esm.sh/canvas-confetti\"}}",
  "description": "Added confetti effect"
}
FieldTypeRequiredDescription
htmlstringnoHTML body content (no <html>/<head>/<body> tags)
cssstringnoCSS styles
jsstringnoJavaScript ES module code
import_mapstringnoJSON import map for bare module specifiers
descriptionstringnoBrief description (saved as README.md)

Response: 200 OK

Errors: 400 if name is invalid or missing. 500 on file write errors.

GET /api/playground?name=...

Get a named playground’s content.

Query Parameters:

ParamTypeRequiredDescription
namestringyesPlayground name

Response (200):

{
  "name": "snake-game",
  "html": "<div id='app'></div>",
  "css": "body { margin: 0; }",
  "js": "console.log('hi')",
  "import_map": "{\"imports\":{}}",
  "description": "Initial setup"
}

Errors: 400 if name is invalid. 404 if playground not found.

GET /api/playground/state?name=...

A playground’s state: a JSON object its page keeps through window.loop.state, stored as state.json in the playground’s dir. A playground without one answers {}.

Errors: 400 invalid name; 404 no such playground.


PATCH /api/playground/state?name=...

Merges the body, a JSON object, into the state; a null value removes its key. Answers with the new state and sends a playground.update event with "kind": "state", which the playground’s panels pass to their page.

Request:

{"score": 12, "draft": null}

Errors: 400 the body isn’t a JSON object; 404 no such playground; 413 the state would grow past 1 MiB.


GET /api/playground/export?name=...

Export a playground as a standalone HTML file with embedded CSS, JS, and import map.

Response (200): text/html with Content-Disposition: attachment; filename="playground-{name}.html".

Errors: 400 if name is invalid.

GET /api/playground/items

List all playground names from both global and project scopes.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringnoIf provided, also includes project-scoped items from the channel’s directory

Response (200):

{
  "items": [
    {"name": "snake-game", "scope": "global"},
    {"name": "my-viz", "scope": "project"}
  ]
}

Returns {"items": []} if no playgrounds exist. Items include a scope field indicating whether they are "global" or "project".

GET /api/playground/serve/{name}

Serve a global playground as a standalone HTML page (used as iframe src).

GET /api/playground/serve-project/{channel_id}/{name}/

Serve a project-scoped playground as a standalone HTML page. Uses path-based routing instead of query parameters so that relative sub-resource URLs (style.css, script.js) resolve correctly via the <base> tag.

PUT /api/playground/share?name=...

Expose a playground publicly over a cloudflared quick tunnel. Accepts scope/channel_id like the other playground endpoints. Returns { "url": "https://<host>/p/<token>", "token": "<32-hex>" }. Idempotent per resolved playground dir — re-sharing returns the same token. Requires playground_share.enabled (else 403). See Playground: Public sharing .

DELETE /api/playground/share?name=...

Stop sharing a playground; 204 on success. Resolves the dir, so any channel/thread mapping to the same playground can unshare it. The tunnel is torn down when the last share is removed.

GET /api/playground/share

With no name: list every active share — { "shares": [{ name, scope, channel_id, url }] }. With ?name=... (+ optional scope/channel_id): return the status of that one playground — { "shared": bool, "url": string }, resolved by dir so the answer is identical for every channel/thread mapping to the same playground.


Tickets

The ticket API manages filesystem-backed tickets stored in .tickets/ within a project directory. Tickets are powered by the github.com/radutopala/ticket library. See Kanban Panel for the frontend UI.

All ticket endpoints require a dir query parameter specifying the directory path. The store opened is always dir/.tickets/ — the same one the tk CLI reads in that directory — so a worktree passed as dir gets the worktree’s own store, not the store of the checkout it was cut from.

GET /api/tickets

List tickets for a project directory.

Query Parameters:

ParamTypeDescription
dirstring(required) Project directory path
statusstringFilter by status (open, in_progress, closed)
tagstringFilter by tag
assigneestringFilter by assignee
typestringFilter by type (task, bug, feature, epic, chore)
sortstringSort field (default: priority)
reverseboolReverse sort order

Response (200):

[
  {
    "id": "tic-a1b2c3d4",
    "title": "Fix login bug",
    "description": "Users can't log in with SSO",
    "status": "open",
    "type": "bug",
    "priority": 1,
    "assignee": "",
    "tags": ["auth", "urgent"],
    "deps": [],
    "parent": "",
    "external_ref": "JIRA-1234",
    "design": "",
    "acceptance": "SSO login works for all providers",
    "notes": [
      { "timestamp": "2026-04-11T09:30:00Z", "content": "Reproduced with the Okta provider only" }
    ],
    "created": "2026-04-10T10:00:00Z",
    "updated": "2026-04-10T10:00:00Z"
  }
]

POST /api/tickets

Create a new ticket.

Request Body:

FieldTypeRequiredDescription
dirstringyesProject directory path
titlestringyesTicket title
descriptionstringnoMarkdown description
typestringnotask (default), bug, feature, epic, chore
priorityintno0–4 (default: 2)
assigneestringnoAssignee name
tagsstring[]noTags
parentstringnoParent ticket ID
external_refstringnoExternal issue reference
designstringnoDesign notes
acceptancestringnoAcceptance criteria

Response (201): The created ticket object.


GET /api/tickets/{id}

Get a single ticket by ID (supports short ID prefix matching).

Query Parameters:

ParamTypeDescription
dirstring(required) Project directory path

Response (200): The ticket object.

Errors: 404 if no ticket matches the ID.


PATCH /api/tickets/{id}

Update ticket fields. Only provided fields are modified.

Request Body:

FieldTypeDescription
dirstring(required) Project directory path
statusstringNew status
titlestringNew title
descriptionstringNew description
typestringNew type
priorityintNew priority (0–4)
assigneestringNew assignee
tagsstring[]Replace tags
depsstring[]Replace dependency list
parentstringNew parent ticket ID
external_refstringNew external reference
designstringNew design notes
acceptancestringNew acceptance criteria

Response: 204 No Content on success.

Errors: 404 if ticket not found; 400 for invalid status/type/priority.

Broadcasts ticket.updated WebSocket event.


DELETE /api/tickets/{id}

Delete a ticket.

Query Parameters:

ParamTypeDescription
dirstring(required) Project directory path

Response: 204 No Content on success.

Broadcasts ticket.deleted WebSocket event.


POST /api/tickets/{id}/notes

Append a timestamped note to a ticket — the same thing tk add-note does. Notes are append-only: no endpoint edits or removes one, and PATCH leaves them untouched. The timestamp is the server’s current time in UTC.

Request Body:

FieldTypeRequiredDescription
dirstringyesProject directory path
contentstringyesNote text (Markdown); leading and trailing whitespace is trimmed

Response (201): The updated ticket object, with the new note last in notes.

Errors: 400 for a missing dir or blank content; 404 if ticket not found.

Broadcasts ticket.updated WebSocket event.


POST /api/tickets/{id}/assign

Assign a worktree to a ticket. This performs an atomic multi-step operation:

  1. Claims the ticket (open → in_progress) with file locking
  2. Detects the current branch of the parent project
  3. Creates a git worktree on branch tk-<ticket-id>
  4. Creates a thread named after the ticket title
  5. Sets the ticket’s assignee to the thread name
  6. Optionally auto-starts an agent with the ticket description

Request Body:

FieldTypeRequiredDescription
dirstringyesProject directory path
channel_idstringyesParent channel ID

Response (200):

{
  "thread_id": "thread-abc123",
  "worktree_path": "/path/to/worktrees/tk-a1b2c3d4"
}

Errors: 409 if the ticket is not in open status (already claimed).

Gate Approvals

When the security gate is enabled, agent containers that trip an approve rule block waiting for a human decision. The gate broadcasts gate.approval_requested on WebSocket; the UI resolves it back with this endpoint. See Security Gate for the rule model.

GET /api/gate/approvals

Snapshot every pending approval the daemon currently knows about, aggregated across all live agent containers. The renderer calls this on every WebSocket onOpen (page reload, network blip, daemon restart) to reconcile its gateApprovals map and the electron dock-bouncer’s pending set against the source of truth — any req_id the client thought was pending but is missing from the snapshot is treated as resolved, and any snapshot entry the client did not know about is added.

Response (200):

{
  "approvals": [
    {
      "req_id": "gate-req-8f1c...",
      "container_id": "ab12cd34ef56",
      "channel_id": "C0123456",
      "kind": "docker-http",
      "target": "POST /containers/abc123/exec",
      "source": "chat",
      "message": "agent wants to exec into a container",
      "details": { "cmd": "bash, -c, whoami", "user": "root" }
    }
  ]
}
FieldTypeDescription
req_idstringCorrelation id, same one carried on gate.approval_requested and accepted by POST /api/gate/approvals/{id}
container_idstringDocker container id that owns the request — useful for cross-referencing with audit logs
channel_idstringChannel the approval is prompting on
kindstringSame set as the event payload ("connect", "execve", "file", "docker-http", "docker-body")
targetstringHuman-readable target
sourcestringOrigin within the container — "chat" for the entrypoint agent, "terminal:<leafId>" for a specific terminal pane. Omitted when unknown
messagestringRule’s message field (omitted when empty)
detailsobjectStructured body summary (omitted when empty); same shape as gate.approval_requested.details

Returns an empty approvals array when nothing is pending. Returns 501 if the gate approval resolver is not configured (gate disabled).

POST /api/gate/approvals/{id}

Resolve a pending gate approval by request id. The id is the req_id from the gate.approval_requested event.

Request Body:

FieldTypeRequiredDescription
decisionstringyesOne of "once", "session", or "deny". once lets the current syscall through; session caches the allow for the container lifetime; deny rejects the syscall
author_idstringnoClicking user’s id. Falls back to local.DefaultAuthorID when empty — Discord/Slack handlers pass the platform user id; the local desktop omits it
{ "decision": "once" }

Response: 204 No Content on success. The gate broadcasts gate.approval_resolved with the decision and actor so the UI can dismiss the card.

Errors: 400 if the path id is missing, the body isn’t valid JSON, or decision is empty. 404 if no pending request matches the id (already resolved, expired, or never existed). 501 if the gate approval resolver is not configured.

POST /api/gate/container-approval

Inbound call from the in-container docker proxy (loop dockerproxy) or seccomp-gate parent (loop syscallwrap) when a rule matches approve. The server looks up the owning per-container Manager by the bearer token, renders an approval prompt on the associated chat channel, and blocks until the user clicks. Not intended to be called by UI clients — the click resolve path is POST /api/gate/approvals/{id} .

Headers:

HeaderValue
AuthorizationBearer <gate token> — the 32-byte-hex bearer the runner minted for this container, read from /run/loop/gate-token (root-only) inside it. Compared in constant time
Content-Typeapplication/json

Request Body:

FieldTypeDescription
kindstring"docker-http" / "docker-body" for the proxy; gate-trap categories ("connect", "execve", "file") for the seccomp path
targetstringHuman-readable target of the operation ("GET /containers/json", "/etc/passwd", etc.)
messagestringRule’s message field — shown to the user verbatim
cache_keystringKey the Manager uses when the user picks “Allow for session”

Response (200):

{ "decision": "allow", "actor": "u-42", "reason": "cache-hit" }
FieldTypeDescription
decisionstring"allow" or "deny"
actorstringClicking user’s id (empty on local desktop)
reasonstringFree-form tag (e.g. "cache-hit", "rate-limited")

Errors: 401 if the Authorization header is missing, malformed, or the token doesn’t match any live container. 400 if the body isn’t valid JSON. 503 if cfg.Gates.Agentgate.Enabled and cfg.Gates.DockerProxy.Enabled are both false (no ContainerApprovalRouter is constructed — neither enforcement layer is running, so no legitimate caller should hit this endpoint).

Workflows

Declarative DAG-based workflow execution. See Workflows for architecture details.

GET /api/workflows

List all available workflow definitions from the merged config.

Query Parameters:

ParamTypeDescription
dir_pathstringOptional project directory for project-level config merge
channel_idstringOptional channel ID — resolves dir_path and parent from DB for three-layer config merge (global → parent → worktree)

Response (200): Each definition is tagged with its config scope ("global" or "project") so the panel can group them. With no channel/dir every entry is global; with a channel, names present only in the project’s config are "project".

[
  {
    "scope": "global",
    "name": "code-review",
    "description": "Review branch changes",
    "inputs": {},
    "nodes": [
      { "id": "diff", "type": "bash", "script": "git diff main...HEAD" },
      { "id": "review", "type": "prompt", "depends_on": ["diff"], "prompt": "Review:\n\n{{.NodeOutputs.diff}}" }
    ]
  }
]

Errors: 501 if the workflow engine is not configured.

POST /api/workflows

Add, update, or delete a workflow definition in the global or project config file. An agent token can only change project workflows (403 otherwise); workflows needs project config trust , so an agent’s change waits for the user, and the user’s own change keeps a trusted project trusted.

Request Body:

FieldTypeRequiredDescription
actionstringYes"add", "update", or "delete"
scopestringNo"global" (default) or "project"
channel_idstringFor project scopeChannel ID to resolve project directory
workflowobjectFor add/updateFull workflow definition (name, description, nodes, inputs)
namestringFor deleteWorkflow name to delete

Response: 204 No Content

Errors: 400 invalid request or workflow definition (for example a plugin node without exactly one of action and tool), or a project config that a symlink leads outside the project, 403 an agent writing outside project scope, 404 workflow not found (update/delete), 409 duplicate name (add).

POST /api/workflows/runs

Start a new workflow run.

Request Body:

FieldTypeRequiredDescription
workflow_namestringyesName of the workflow to run
channel_idstringnoChannel context for prompt nodes
dir_pathstringnoProject directory for bash/prompt nodes
inputsobjectnoInput values keyed by input name

Response (201):

{
  "run_id": "wfr-a1b2c3d4e5f67890"
}

Errors: 400 if workflow_name is missing or request body is invalid JSON. 500 on engine errors (workflow not found, missing required inputs, etc.). 501 if the workflow engine is not configured.

GET /api/workflows/runs

List workflow runs.

Query Parameters:

ParamTypeDescription
channel_idstringOptional filter by channel
limitintMax results per page (default 50, capped at 1000)
offsetintNumber of rows to skip for pagination (default 0; non-positive values are treated as 0)

When no channel_id is provided, each run is enriched with channel_name and channel_worktree resolved by walking up the parent chain to the nearest named ancestor — the global Workflows panel uses this to label unnamed threads. The list view paginates via infinite scroll (see Workflows ).

Response (200):

[
  {
    "id": "wfr-a1b2c3d4e5f67890",
    "workflow_name": "code-review",
    "channel_id": "",
    "status": "completed",
    "started_at": "2026-04-11T10:00:00Z",
    "finished_at": "2026-04-11T10:02:30Z",
    "channel_name": "dm",
    "channel_worktree": false
  }
]

Errors: 501 if the workflow engine is not configured.

GET /api/workflows/runs/{id}

Get a workflow run with all node statuses and outputs.

Response (200):

{
  "run": {
    "id": "wfr-a1b2c3d4e5f67890",
    "workflow_name": "code-review",
    "status": "completed",
    "inputs": "{\"issue_url\":\"https://...\"}",
    "workflow_def": "{\"name\":\"code-review\",\"nodes\":[...]}",
    "started_at": "2026-04-11T10:00:00Z",
    "finished_at": "2026-04-11T10:02:30Z"
  },
  "node_runs": [
    {
      "run_id": "wfr-a1b2c3d4e5f67890",
      "node_id": "diff",
      "status": "success",
      "input": "git diff main...HEAD",
      "session_id": "",
      "output": "+added line\n-removed line",
      "attempt": 1,
      "started_at": "2026-04-11T10:00:00Z",
      "finished_at": "2026-04-11T10:00:05Z",
      "last_heartbeat_at": "2026-04-11T10:00:04Z"
    }
  ]
}

A plugin tool node’s run also has data, the tool’s structured_content, when the tool answered with one.

Errors: 404 if the run does not exist. 501 if the workflow engine is not configured.

POST /api/workflows/runs/{id}/resume

Resume a paused workflow run (e.g. after an approval node). The response text becomes the approval node’s output.

Request Body:

FieldTypeRequiredDescription
responsestringnoResponse text for the approval node (defaults to "approved" if empty)
{ "response": "approved" }

Response: 204 No Content on success.

Errors: 400 if request body is invalid JSON. 404 if the run does not exist. 500 if no pending approval exists for the run. 501 if the workflow engine is not configured.

POST /api/workflows/runs/{id}/cancel

Cancel a running workflow. Cancels the context for all active nodes.

Response: 204 No Content on success.

Errors: 404 if the run does not exist. 500 on engine errors. 501 if the workflow engine is not configured.

POST /api/workflows/runs/{id}/retry

Retry a completed, failed, or cancelled workflow run. Creates a new run with the same workflow definition and inputs.

Response (201):

{
  "run_id": "wfr-b2c3d4e5f6a78901"
}

Errors: 404 if the run does not exist. 500 on engine errors (run still active, workflow definition not found, etc.). 501 if the workflow engine is not configured.

DELETE /api/workflows/runs/{id}

Delete a workflow run. If the run is active (running or paused), it is cancelled first.

Response: 204 No Content on success.

Errors: 404 if the run does not exist. 500 on engine errors. 501 if the workflow engine is not configured.