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, andAccess-Control-Allow-Headers: Content-Type, Authorization. PreflightOPTIONSrequests return204 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 returntext/plain; charset=utf-8. - Error responses: Plain text body with the appropriate HTTP status code.
Common Error Codes
| Code | Meaning |
|---|---|
| 400 | Bad request – missing/invalid parameters or request body |
| 401 | Missing or invalid API token |
| 403 | The token is valid but may not make this request (an agent token on an owner-only route, or naming another project) |
| 404 | Resource not found |
| 413 | Request entity too large (file operations) |
| 500 | Internal server error |
| 501 | Feature not configured (service dependency is nil) |
| 503 | Service 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
| Caller | Token | Where it lives |
|---|---|---|
Owner: the desktop app, the loop CLI, host tools | Owner token, 32 random bytes in hex | api-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
| Route | Why |
|---|---|
GET /api/health | Liveness checks before a token is at hand |
POST /api/gate/container-approval | Checks 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:
| Field | Type | Description |
|---|---|---|
kind | string | raw (a channel root’s files), playground or plugin (a plugin’s panel bundle) |
channel_id | string | raw: the channel (required). playground: set for a project playground, empty for a global one |
root | int | raw: the channel root index |
name | string | playground: 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:
| Param | Type | Description |
|---|---|---|
query | string | Filter channels by name (case-insensitive substring match) |
platform | string | Filter 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_pathisdir_pathwith the daemon user’s home dir shown as~, for display; the header shows it.container_runningis determined by querying the Docker daemon for running containers.agent_runningindicates whether an active Claude agent run exists for the channel. A run is registered before itsagent.status“running” event is broadcast, so a response to a request made after that event reflects the run.review_runningis true while a review run or a dedup pass runs on the channel’s review session (its status isreviewing, orverifyingduring 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 fromreview.statusevents.review_max_iterationsis the project’sreview.max_iterations(global, then project and worktree config;3unless set), where the Review panel’s pass count starts.branchis resolved by runninggit rev-parse --abbrev-ref HEADin the channel’s directory.commitis the short commit hash fromgit rev-parse --short HEAD, andsubjectits subject line.upstreamis the branch’s tracking branch (e.g.origin/main), withahead/behindcounting the commits between them. Omitted when there’s none.sync_baseis set on a worktree thread whose base branch still resolves, withbase_ahead/base_behindcounting the commits between the checkout and it.worktreeis true for threads created viaPOST /api/worktrees.root_dir_pathis 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 thedir_pathof 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_overrideare the model and effort picked for the channel (seePATCH /api/channels/{id}/agent-config). Omitted when it inherits the config’s.last_activity_atis 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.descriptionis what the channel or thread is for, set viaPOST /api/channels/{id}/description. Omitted when empty.ticket_urlis the URL of the channel or thread’s ticket, set viaPOST /api/channels/{id}/ticket. Omitted when unset.trust_pendingistruewhen the project config’s host-reaching fields changed since you last trusted them, so they don’t apply yet (seeGET /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_attentionis what the thread’s PR/MR needs (see PR watching ):failedfor a failure no agent is fixing, which lights the sidebar’scipill,fixingwhile the thread’s agent was sent to fix one (ci…), orconflictfor 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 fromchannel.prevents.unresolved_threadscounts the PR/MR’s unresolved review threads, which light the sidebar’scmtpill. Omitted when there are none, and when PR watching is off. Patched live fromchannel.prevents.plugin_badgesare the badges plugins set on the row withPUT /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 fromplugin.badgeevents.task_idis 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.lockedis true when the channel/thread is guarded against accidental deletion (toggle viaPATCH /api/channels/{id}/lock).DELETE /api/channels/{id}andDELETE /api/threads/{id}return409 Conflictwhile 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"
}| Field | Type | Required | Description |
|---|---|---|---|
dir_path | string | yes | Absolute path to project directory |
platform | string | no | Target 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"
}| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Channel display name |
author_id | string | no | User to invite to the new channel |
channel_id | string | no | Source channel for platform/guild inference |
platform | string | no | Target 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel or thread ID |
Request:
{"locked": true}| Field | Type | Required | Description |
|---|---|---|---|
locked | bool | yes | New 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel or thread ID |
Request:
{"name": "New Name"}| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | New 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel or thread ID |
Request:
{"description": "Fixes the login redirect"}| Field | Type | Required | Description |
|---|---|---|---|
description | string | no | The 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel or thread ID |
Request:
{"ticket_url": "https://example.atlassian.net/browse/PROJ-123"}| Field | Type | Required | Description |
|---|---|---|---|
ticket_url | string | no | The 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"
}| Field | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Parent channel ID |
name | string | yes | Thread display name |
author_id | string | no | Thread creator’s user ID |
message | string | no | Initial message content |
Response (201):
{"thread_id": "thread_abc123"}Behavior notes:
- When an
IncomingMessageHandler(orchestrator) is configured, the initial message is not stored viaCreateThread. Instead,HandleThreadCreatedis called asynchronously to store it as a user message and trigger the agent. - Broadcasts a
channel.createdevent to the parent channel via the EventsHub. - Thread inherits the parent’s
dir_path,session_id,permissions,guild_id, andplatform.
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": truein the message list, the timeline andmessage.createdevents. The new thread holds the reply’s session withfork_pendingand 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
parentUuidfrom 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’spromptis 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:
| Param | Type | Description |
|---|---|---|
id | string | Thread 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
}| Field | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Target channel or thread ID |
content | string | yes | Message text |
mode | string | no | Agent mode hint (e.g. "plan") |
interrupt | bool | no | When 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_seconds | int | no | When > 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
IncomingMessageHandleris set, the message is dispatched asynchronously with a detached context (the HTTP response returns immediately). - When no handler is set, falls back to direct
PostMessagevia the configured message sender. interrupt=truerequires both aRunCancellerand aStoreon the server; the orchestrator wires both during startup.delay_seconds > 0routes throughHandleIncomingMessageDelayedwith amsg_idpicked by the handler, which stampsnot_beforeon the inserted row. Because the row is not yet due, the immediate drain claims nothing; the orchestrator’s delay poller re-drains the channel oncenot_beforepasses (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:
| Param | Type | Description |
|---|---|---|
id | string | msg_id of the message to delete (platform-specific message ID) |
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel 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.deletedWebSocket 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:
ClaimNextPendingonly seesis_processed=0 AND is_triggered=1 AND is_running=0rows, 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel 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-sessioninstead 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
}
]| Field | Type | Description |
|---|---|---|
type | string | Registry container type: agent (chat runs), shell (terminal panes), chrome, … |
cpu_percent | number | Percent of one CPU; can exceed 100 on multi-core containers |
mem_usage / mem_limit | number | Bytes; 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel ID |
Query Parameters:
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
offset | int | 0 | – | Skip the first N files (files are returned newest-first by date) |
limit | int | 50 | 500 | Number of files to return |
Response (200):
{
"files": [
{
"date": "2026-04-24",
"size": 12456,
"last_modified": "2026-04-24T18:02:11Z"
}
],
"total": 1
}Behavior notes:
dateis parsed out of the filename and validated againstYYYY-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": []}with200rather than404.
Errors: 501 if the audit-dir resolver is not configured.
DELETE /api/channels/{id}/audit/{date}
Remove one audit file from disk.
Path Parameters:
| Param | Type | Description |
|---|---|---|
id | string | Channel ID |
date | string | YYYY-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:
| Param | Type | Description |
|---|---|---|
id | string | Channel or thread ID |
Query Parameters (cursor mode):
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
cursor | int64 | 0 | – | Fetch messages older than this message ID |
limit | int | 50 | 200 | Number of messages to return |
Query Parameters (around mode):
| Param | Type | Description |
|---|---|---|
around | int64 | Center message ID; returns messages surrounding it |
limit | int | Total 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+1messages to determine if more exist. If so,next_cursoris 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_cursoris 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}/timelinefor 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel or thread ID |
msg_id | string | msg_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:
| Param | Type | Description |
|---|---|---|
id | string | Channel or thread ID |
msg_id | string | msg_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 }| Field | Type | Required | Description |
|---|---|---|---|
command | string | yes | The script |
description | string | yes | What it waits for; shown in the chat and the wake-up message |
timeout_sec | int | no | Stop the script after this long. 0 means one hour; at most 24 hours |
interrupt | bool | no | Send 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" }| Field | Type | Required | Description |
|---|---|---|---|
mode | string | yes | fork: 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:
| Param | Type | Description |
|---|---|---|
id | string | Channel or thread ID |
Query Parameters:
| Param | Type | Default | Max | Description |
|---|---|---|---|---|
cursor_position | int64 | 0 | – | 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_id | int64 | 0 | – | Tiebreaker id for rows that share cursor_position (in particular legacy rows where chain_position = 0). |
limit | int | 50 | 200 | Number 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 }
}| Field | Type | Description |
|---|---|---|
items[].kind | string | One of "message", "thinking", "tool_use", "tool_result" |
items[].position | int64 | Per-channel monotonic chain position. 0 for legacy rows that pre-date the timeline feature. |
items[].id | int64 | Row id; used as a stable tiebreaker for cursor pagination |
items[].data | object | Present 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[].text | string | Present 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[].truncated | bool | true when the row’s content was truncated to fit the inline cap |
items[].tool_use_id | string | Pairs tool_use rows with their matching tool_result row (and matching live tool.use / tool.result events) |
items[].tool_name | string | Present on tool_use rows |
items[].tool_input | string | Present on tool_use rows; serialised tool input (truncated to 8 KiB inline) |
items[].is_error | bool | Present on tool_result rows; true when the tool failed |
items[].trigger_msg_id | string | The 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_cursor | object|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 + 1rows to determine whether a next page exists. If so,next_cursoris set to the(chain_position, id)of the row past the cap. - Legacy rows (chat from before this feature shipped) all carry
chain_position = 0and are ordered byidwithin 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
truncatedflag tells the UI when a block was clipped. - Live
agent.thinking/tool.use/tool.resultSSE 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:
| Param | Type | Default | Max | Required | Description |
|---|---|---|---|---|---|
q | string | – | – | yes | Search query |
limit | int | 20 | 50 | no | Max 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:
| Param | Type | Default | Max | Required | Description |
|---|---|---|---|---|---|
q | string | – | – | yes | Text to find (trimmed) |
limit | int | 500 | 1000 | no | Max 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
}| Field | Type | Description |
|---|---|---|
available | bool | false for Slack and Discord channels and scheduled tasks’ threads, which never learn |
learn | string | The channel’s setting: "on", "off", or empty to inherit default_learn |
default_learn | bool | learn.enabled from the merged config; false when the config fails to load |
enabled | bool | The effective switch; always false when not available |
learn_channel_id | string | The hidden learn thread; empty until the channel’s first learn pass |
running | bool | A 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"
}
]
}| Field | Type | Description |
|---|---|---|
message_id | string | The turn (its last bot message’s msg_id) whose learn pass filed it; omitted when unknown |
kind | string | prompt_shortcut, bash_shortcut, scheduled_task, gate_rule, mount, rename, description or ticket_url |
payload | string | The kind’s payload as a JSON string (see POST
) |
status | string | pending, applying, applied, dismissed, failed or withdrawn |
error | string | Why the last apply failed; omitted when empty |
withdrawn_reason | string | Why 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."}
]
}| Field | Type | Description |
|---|---|---|
proposals | array | New proposals, at most 5 |
proposals[].replaces | int | Optional id of an earlier proposal this one supersedes; it’s withdrawn with the reason replaced by a newer proposal |
withdraw | array | Earlier 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):
| Kind | Payload |
|---|---|
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
}
]
}| Field | Type | Description |
|---|---|---|
message_id | string | The reviewed turn’s last bot message (msg_id) |
learn_channel_id | string | The channel’s hidden learn thread, where the pass runs |
status | string | queued, running, done or failed. Passes from before passes queued one after another may also be superseded: a newer pass replaced it before it started. |
error | string | Why the pass failed; omitted when empty |
message_row_id | int | The 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"
}| Field | Description |
|---|---|
path | The .loop/config.json the proposal edits (see Where learn proposals are written
) |
diff | The 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) |
error | Why 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}| Field | Type | Description |
|---|---|---|
available | bool | false for Slack and Discord channels and scheduled tasks’ threads, which are never explained |
explain | string | The channel’s setting: "on", "off", or empty to inherit default_explain |
default_explain | bool | explain.enabled from the merged config; false when the config fails to load |
enabled | bool | The 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`..."
}
]| Field | Type | Description |
|---|---|---|
message_id | string | The explained turn’s last bot message (msg_id) |
explain_channel_id | string | The channel’s hidden explain thread, where the run goes |
status | string | queued, running, done or failed |
content | string | The explanation in markdown; empty until done |
error | string | Why the run failed; omitted when empty |
message_row_id | int | The bot message’s row id, for linking to it; omitted when the message is gone |
prompt / reply | string | The 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
}| Field | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel to run the task in |
schedule | string | yes | Cron expression, Go duration, or RFC3339 timestamp |
type | string | yes | cron, interval, or once |
prompt | string | no | Prompt text for the agent (required unless workflow_name or bash_script is set; a task with none of them is a 400) |
template_name | string | no | Template identifier for deduplication |
auto_delete_sec | int | no | Auto-delete thread after N seconds |
worktree | bool | no | Run the agent in an isolated git worktree |
origin_branch | string | no | Base branch for worktree tasks. Auto-detected on first run if omitted. |
update_before_run | bool | no | Prepend git fetch/rebase instructions to the prompt before each run |
workflow_name | string | no | Name of a workflow to run on schedule (mutually exclusive with prompt) |
workflow_inputs | string | no | JSON object of inputs to pass to the workflow |
bash_script | string | no | Shell 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:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel ID |
worktrees | bool | no | true also lists the tasks of the channel’s worktree threads |
threads | bool | no | true 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:
| Param | Type | Description |
|---|---|---|
id | int64 | Task 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:
| Param | Type | Description |
|---|---|---|
id | int64 | Task 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:
| Param | Type | Description |
|---|---|---|
id | int64 | Task 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:
| Param | Type | Description |
|---|---|---|
id | int64 | Task 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'"
}| Field | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel context |
author_id | string | no | Defaults to "local-user" |
command | string | yes | Command string |
Supported commands:
| Command | Arguments | Description |
|---|---|---|
tasks | – | List tasks |
status | – | Show status |
readme | – | Show README |
template-list | – | List templates |
iamtheowner | – | Claim ownership |
task | task_id | Show task |
cancel | task_id | Cancel task |
toggle | task_id | Toggle task |
stop | [channel_id] | Stop agent |
template-add | name | Add template |
schedule | type=... schedule=... prompt=... | Schedule task |
edit | task_id [key=value ...] | Edit task |
allow_user | target_id [role] | Grant access |
deny_user | target_id | Revoke access |
Response: 204 No Content
Behavior notes:
- The command string supports quoted arguments (single or double quotes).
- Key-value pairs use
key=valuesyntax. - Unknown commands return
400with"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:
| Param | Type | Description |
|---|---|---|
id | string | Channel 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:
| Param | Type | Default | Description |
|---|---|---|---|
path | string | "." | Relative path within the channel’s directory |
root | int | 0 | Root 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:
| Param | Type | Required | Description |
|---|---|---|---|
path | string | yes | Relative path to the file |
root | int | no | Root directory index (0 = primary, 1+ = extra directories) |
ref | string | no | A 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-8with file contents as body. - Image files (
.png,.jpg/.jpeg,.gif,.webp):Content-Typeset to the matchingimage/*MIME with the raw bytes as the body. NoX-File-Binaryheader — 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 withContent-Typeset to the matchingvideo/*orapplication/pdfMIME andAccept-Ranges: bytes, soRangerequests return206 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: trueheader andContent-Lengthset.
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 orX-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:
| Param | Type | Required | Description |
|---|---|---|---|
path | string | yes | Relative path to the file |
root | int | no | Root 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
0644for 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:
| Param | Type | Required | Description |
|---|---|---|---|
path | string | yes | Relative path to the file or directory |
root | int | no | Root 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:
| Param | Type | Description |
|---|---|---|
root | int | Root directory index (0 = primary, 1+ = extra directories) |
path | string | Relative 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: nosniffandContent-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:
| Param | Type | Required | Description |
|---|---|---|---|
path | string | yes | Relative path to the directory to create |
root | int | no | Root 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_pathand anyextra_dirsfrom project config, in order. The first root that contains the path wins; theroot_indexin the response refers to that root (compatible with therootquery 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 returnexists: 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:
| Param | Type | Description |
|---|---|---|
q | string | Fuzzy query — every rune must appear in the relative path in order, case-insensitively. Empty q returns the first N entries. |
limit | int | Max 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_pathfirst, thenextra_dirs).root_indexindicates which root the match came from (compatible with therootquery parameter on file read/write endpoints). - Always skips
.git,node_modules,vendor,.next,dist,build,__pycache__subtrees. - Honors the top-level
.gitignorein each root (basename and full-relpath patterns; negation patterns and nested.gitignorefiles are ignored). - Walk stops once
limitmatches 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"
}| Field | Type | Required | Description |
|---|---|---|---|
data | string | yes | Standard base64 (+/= alphabet) of the raw image bytes. |
media_type | string | yes | One 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 withMkdirAllon first paste..loop/should be gitignored. Both are opened within the workspace: a.looporpastessymlink leading out of it fails the paste with500. - 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—.jpgforimage/jpeg,.png/.gif/.webpotherwise. - 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:
- Rejects absolute paths.
- Rejects
..traversal components. - Resolves symlinks and verifies the real path stays under the root directory.
- For writes to nonexistent files, validates the parent directory instead.
- 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:
| Param | Type | Description |
|---|---|---|
source | string | When provided with target, switches to branch-to-branch diff mode (git diff source..target). status is omitted in this mode. |
target | string | Branch / ref name for branch-to-branch diff mode. |
commit | string | A commit hash (4–64 hex digits). Switches to single-commit mode: the changes that commit introduced. Takes precedence over source/target. |
root | int | Root 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:
| Field | Type | Description |
|---|---|---|
path | string | File path relative to the repo root |
old_path | string | Original path (set when the file was renamed; omitted otherwise) |
additions | int | Lines added in this entry |
deletions | int | Lines deleted in this entry |
binary | bool | True for binary files |
status | string | "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), andgit ls-files --others --exclude-standard(untracked). Conflicts are surfaced viagit diff --diff-filter=U. - The frontend parses
staged_diff/unstaged_diff/untracked_diff/conflict_diffindependently so the per-bucketstatustag 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 singlegit diffis returned indiff; the per-status fields and thestatustag on each file are omitted. - Single-commit mode (
?commit=<sha>): runsgit 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 returns400; a hash that doesn’t resolve to a commit returns404. - 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
filesarray. - Files are sorted by (path, status priority) —
conflict<staged<unstaged<untracked. - The
rootparameter mirrors the same field on file endpoints:0(default) targets the channel’s primarydir_path;1+indexes intoextra_dirsfrom the project’s.loop/config.json. Both uncommitted and branch-to-branch modes honorroot.
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,isDraftand falls back to agh pr listquery whenpr viewreports no match. - The
ghaccount is picked by thegithub.gh_userconfig key (mergeable per-project via.loop/config.json); empty falls back to whichever accountghcurrently has active. - A repo without an
originremote looks nothing up. Anoriginon a GitLab host (its name containsgitlab, or theglabconfig lists it) runsglab mr list --source-branch <branch> --all, newest first;gitlab.glab_config_dirpicks the config, so the account, it uses. Any other host goes togh, GitHub Enterprise included. - Environmental failures (
ghorglabnot installed, network) degrade topresent: falserather 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"}| Field | Type | Description |
|---|---|---|
when_ready | bool | Arm “merge when ready” instead of merging now |
method | string | squash, 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:
| Param | Type | Default | Description |
|---|---|---|---|
root | int | 0 | Root 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
brancheslist (git won’t allow switching to them). - The main worktree is excluded from the
worktreeslist. thread_idis populated when the worktree has been imported as a thread (viaPOST /api/worktreesorPOST /api/worktrees/import).- The
rootparameter mirrors the same field on/api/channels/{id}/diffand/api/channels/{id}/commits:0(default) targets the channel’s primarydir_path;1+indexes intoextra_dirsfrom 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:
| Param | Type | Default | Description |
|---|---|---|---|
branch | string | HEAD | Branch name to list commits from |
limit | int | 50 | Maximum number of commits to return (max 200) |
skip | int | 0 | Number of commits to skip (for pagination) |
root | int | 0 | Root 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
skipparameter enables lazy pagination — the frontend loads pages of 50 commits and fetches more on scroll. - The
rootparameter mirrors the same field on/api/channels/{id}/diffand other file endpoints:0(default) targets the channel’s primarydir_path;1+indexes intoextra_dirsfrom 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-sessionworks on the first message. - The thread’s
DirPathpoints to the worktree directory;Worktreeflag 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_pathis a real git worktree (checked againstgit worktree list --porcelain). - Idempotent: if a thread already exists for the worktree path, returns it with
200instead of creating a duplicate. - Copies the parent’s Claude session file to the worktree’s project dir for
--fork-sessionsupport. - 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:
| Field | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Parent channel ID that owns the worktree |
worktree_path | string | yes | Absolute path to the worktree directory |
thread_id | string | no | Thread ID to delete (if the worktree was imported as a thread) |
Response: 204 No Content on success.
Behavior notes:
- Runs
git worktree remove --forceon the worktree path, thengit worktree prune. - If
thread_idis provided, also deletes the thread record from the database and broadcasts achannel.deletedevent. As withDELETE /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:
| Param | Type | Required | Description |
|---|---|---|---|
limit | int | no | Max functions per page (default 50, max 100). |
offset | int | no | Start 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:
| Param | Type | Required | Description |
|---|---|---|---|
limit | int | no | Max clusters per page (default 25, max 50). |
offset | int | no | Start 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_idandreview_runs: the latest review, which aPOST .../review/startbegins, and the history ids of its runs, one per iteration of its loop. An agent comment’srun_idis the run that reported it, so the comments whoserun_idis inreview_runsare the latest review’s.review_head: the commit areviewmode start pinned its loop to. While thatreview-looprun is running or paused, the review’s runs, Sync and dedup check out this commit instead of the PR’s newer head. Absent forreview-fixstarts, 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:
- The dedup pass runs over the whole session. If it fails, the failure is logged and the step goes on.
- 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 throughupdate_review_comment, with the impact (blockingornice_to_have) of a real one. It also assesses each unresolved GitHub thread a person started throughassess_review_thread: addressed, or still open with an impact. Those two tools are the only changes the agent may make. The prompt isreview.verify_prompt, or the built-in one, followed by the PR, the reviewed commit, the checkout and the dedup outcome. - Loop records the head as reviewed (
reviewed_sha) and posts the agent’s reply, a per-comment summary, to the channel, followed byOutcome: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:
mode | Behaviour |
|---|---|
"" | 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"
}| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | Natural language search query |
top_k | int | no | Number of results (default: 5) |
dir_path | string | no* | Project directory for scoping |
channel_id | string | no* | 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"
}| Field | Type | Required | Description |
|---|---|---|---|
dir_path | string | no* | Project directory containing memory files |
channel_id | string | no* | 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:
| Param | Type | Required | Description |
|---|---|---|---|
dir_path | string | no* | Project directory |
channel_id | string | no* | 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:
| Param | Type | Required | Description |
|---|---|---|---|
path | string | yes | Absolute 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:
| Param | Type | Required | Description |
|---|---|---|---|
after | integer | no | Waits (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).
| Op | Fields | Does |
|---|---|---|
select_channel | channel_id | Opens the channel |
set_tab | tab | Switches the layout tab |
create_tab | tab?, 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_tab | tab, name | Renames the tab; the result has its new tab |
remove_tab | tab | Removes the tab, closing its terminals if it’s the open one; the last tab stays |
replace_pane | pane, panel, open_mode?, item?, scope? | Puts a new pane in the pane’s place |
add_pane | panel, 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_pane | pane | Closes the pane |
maximize_pane | pane | Makes the pane fill the tab |
restore_pane | Puts a maximized pane back | |
open_side | view | Opens 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_side | Closes the side view | |
open_file | path, line? | Opens a file of the channel’s roots in the editor |
show_message | message_id | Scrolls 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_input | pane, 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_output | pane, 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_for | pane, 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:
| Action | Params | Description |
|---|---|---|
navigate | url | Navigate 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_click | x, y, button, click_count | Click at coordinates |
mouse_move | x, y | Move mouse |
mouse_scroll | x, y, delta_x, delta_y | Scroll |
mouse_down | x, y, button | Mouse button down |
mouse_up | x, y, button | Mouse button up |
key_press | key | Press a key |
type_text | text | Type text |
click_ref | refs, ref_index | Click element by ref |
screenshot | — | Capture screenshot |
evaluate_js | expression | Evaluate JavaScript |
list_tabs | — | List all open tabs |
new_tab | url | Open a new tab |
switch_tab | target_id | Make a tab the active one and bring it to the front |
close_tab | target_id | Close a tab |
resize_window | width, height | Resize viewport |
scroll_into_view | backend_node_id | Scroll element into view |
read_console | pattern, only_errors, clear, limit | Read console messages |
read_network | pattern, clear, limit | Read network requests |
Responses:
| Code | Description |
|---|---|
| 200 | JSON response with result, error, image, element_refs, tabs, page_info, or screenshot_path |
| 400 | Missing channel_id or invalid JSON |
| 503 | Browser 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:
| Message | Direction | Description |
|---|---|---|
start | Client → Server | Initialize CDP connection and screencast for a channel |
stop | Client → Server | Stop the browser session |
screencast | Client → Server | Start screencast frame streaming (with width/height) |
input | Client → Server | Mouse/keyboard input events |
| Binary frames | Server → Client | JPEG screencast frames |
started | Server → Client | CDP connected, ready |
stopped | Server → Client | Session stopped |
tabs | Server → Client | Tab list update |
tab_switched | Server → Client | Active tab changed |
tab_created | Server → Client | New tab opened |
tab_closed | Server → Client | Tab closed |
error | Server → Client | Error 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"
}
]| Field | Type | Description |
|---|---|---|
container_id | string | Docker container ID |
channel_id | string | Channel the container belongs to |
type | string | "agent", "shell", or "chrome" |
status | string | "running", "stopped", or "pending-removal" |
container_name | string | Docker container name |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last status change timestamp |
remove_at | string? | 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.
| Route | Capability | Answer |
|---|---|---|
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/head | git.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.write | cancels 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.read | as GET /api/channels/{id}/quality/complexity; 503 before the first scan |
POST /api/plugin/v1/events | body {"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/notify | ui.notification.notify | body {"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.write | body {"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.write | removes that file → 204, also when it’s already gone |
PUT /api/plugin/v1/badge | ui.badge | body {"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/badge | ui.badge | removes the plugin’s badge → 204, also when there was none; broadcasts plugin.badge with an empty text |
POST /api/plugin/v1/cards | ui.notification.card | body {"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/commands | ui.run | as 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_switch | body {"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_switch | forgets the switch’s state there → 204, so it shows off; broadcasts plugin.composer_switch |
POST /api/plugin/v1/channel/live/control | live.control | body {"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.control | 204; 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.control | 204; lets the session idle out again |
POST /api/plugin/v1/ui/open_file | ui.open_file | body {"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:
| Param | Required | Description |
|---|---|---|
channel_id | Yes | Channel 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"
}
]| Status | Description |
|---|---|
| 200 | JSON array (empty [] if no agents) |
| 400 | Missing channel_id |
| 500 | Channel or side chat lookup failed |
| 503 | Agent 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.
| Status | Description |
|---|---|
| 200 | Updated AgentInfo JSON |
| 400 | Missing channel_id or invalid JSON |
| 404 | Agent not found |
| 503 | Agent registry not configured |
DELETE /api/agents/{id}
Unregister an agent from the registry. Called by the MCP server on graceful shutdown.
Path Parameters:
| Param | Type | Description |
|---|---|---|
id | string | Agent ID (e.g. "docker-agent-0") |
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel 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"
}| Status | Description |
|---|---|
| 204 | Message typed into the agent’s terminal, or queued in the chat |
| 400 | Missing 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) |
| 404 | Target agent not found |
| 409 | Target agent has no terminal, or its terminal session is gone; messages not configured for a chat |
| 500 | Channel or side chat lookup failed |
| 503 | Agent 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}"
}| Field | Type | Description |
|---|---|---|
config | object | Parsed config values |
raw | string | Raw 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}"
}| Field | Type | Required | Description |
|---|---|---|---|
raw | string | yes | HJSON 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:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel 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:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | no | Channel ID to merge project-level shortcuts |
Response (200):
[
{
"name": "coverage",
"description": "Run coverage check",
"prompt": "Run make coverage-check and report results"
}
]| Field | Type | Description |
|---|---|---|
name | string | Shortcut identifier |
description | string | Human-readable description |
prompt | string | Resolved 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:
| Field | Type | Required | Description |
|---|---|---|---|
action | string | yes | add, update, or delete |
name | string | yes | Shortcut name |
scope | string | no | global (default) or project |
channel_id | string | conditional | Required when scope is project |
description | string | no | Human-readable description (add/update) |
prompt | string | conditional | Inline prompt text (required for add/update unless prompt_path is set) |
prompt_path | string | conditional | Path 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.loopor.loop/config.jsonthat is a symlink outside the project404— 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:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | no | Channel ID to merge project-level shortcuts |
Response (200):
[
{
"name": "make lint",
"description": "Run the linter",
"command": "make lint"
}
]| Field | Type | Description |
|---|---|---|
name | string | Shortcut identifier |
description | string | Human-readable description |
command | string | Resolved 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:
| Field | Type | Required | Description |
|---|---|---|---|
action | string | yes | add, update, or delete |
name | string | yes | Shortcut name |
scope | string | no | global (default) or project |
channel_id | string | conditional | Required when scope is project |
description | string | no | Human-readable description (add/update) |
command | string | conditional | Inline command text (required for add/update unless command_path is set) |
command_path | string | conditional | Path 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.loopor.loop/config.jsonthat is a symlink outside the project404— shortcut not found (update/delete)409— duplicate name (add)
PUT /api/config/project
Save project config for a channel. Owner-only.
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel 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:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel 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…"
}| Field | Description |
|---|---|
trusted | The fields apply as written |
current | The fields as the file has them now, as indented JSON |
approved | The version last trusted, which applies while trusted is false; "" if never trusted |
diff | approved → current as a unified diff, from /dev/null if never trusted; "" while trusted |
hash | Identifies 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:
| Param | Type | Required | Description |
|---|---|---|---|
file | string | no | The 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}
]
}| Field | Description |
|---|---|
source | What 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, removed | Lines changed from the revision before it; the oldest revision counts every line as added |
missing | true 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"}
]
}| Field | Description |
|---|---|
file | Relative to root, with / separators; the file to pass to GET /api/config/history |
revisions | How many revisions are kept |
updated_at | When 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:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel ID; worktree channels use their root project’s config |
file | string | no | As 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:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel 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:
| Param | Type | Required | Description |
|---|---|---|---|
against | integer | no | A 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"
}| Field | Description |
|---|---|
dir | The backup directory, with ~/ expanded; omitted when no path is set |
keep_last, keep_daily, keep_weekly, keep_monthly | The retention tiers; 0 turns a tier off |
recipient | The public key new backups are encrypted to; omitted when they aren’t encrypted |
running | A backup is being written |
last | The newest backup in dir; omitted when there’s none. encrypted is set for a .zip.age |
count, total_size | How many backups are in dir, and their size in bytes |
next_at | When the next scheduled backup is due; omitted when backups aren’t enabled |
error, error_at | Why 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:
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | yes | "workflows" or "shortcuts" |
Canonical names per kind:
| Kind | Names |
|---|---|
workflows | review-loop, review-fix-loop |
shortcuts | builtin 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:
| Param | Type | Required | Description |
|---|---|---|---|
name | string | yes | Playground name (alphanumeric, hyphens, underscores, max 64 chars) |
scope | string | no | "global" (default) or "project" |
channel_id | string | no | Required 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"
}| Field | Type | Required | Description |
|---|---|---|---|
html | string | no | HTML body content (no <html>/<head>/<body> tags) |
css | string | no | CSS styles |
js | string | no | JavaScript ES module code |
import_map | string | no | JSON import map for bare module specifiers |
description | string | no | Brief 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:
| Param | Type | Required | Description |
|---|---|---|---|
name | string | yes | Playground 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:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | no | If 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:
| Param | Type | Description |
|---|---|---|
dir | string | (required) Project directory path |
status | string | Filter by status (open, in_progress, closed) |
tag | string | Filter by tag |
assignee | string | Filter by assignee |
type | string | Filter by type (task, bug, feature, epic, chore) |
sort | string | Sort field (default: priority) |
reverse | bool | Reverse 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:
| Field | Type | Required | Description |
|---|---|---|---|
dir | string | yes | Project directory path |
title | string | yes | Ticket title |
description | string | no | Markdown description |
type | string | no | task (default), bug, feature, epic, chore |
priority | int | no | 0–4 (default: 2) |
assignee | string | no | Assignee name |
tags | string[] | no | Tags |
parent | string | no | Parent ticket ID |
external_ref | string | no | External issue reference |
design | string | no | Design notes |
acceptance | string | no | Acceptance criteria |
Response (201): The created ticket object.
GET /api/tickets/{id}
Get a single ticket by ID (supports short ID prefix matching).
Query Parameters:
| Param | Type | Description |
|---|---|---|
dir | string | (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:
| Field | Type | Description |
|---|---|---|
dir | string | (required) Project directory path |
status | string | New status |
title | string | New title |
description | string | New description |
type | string | New type |
priority | int | New priority (0–4) |
assignee | string | New assignee |
tags | string[] | Replace tags |
deps | string[] | Replace dependency list |
parent | string | New parent ticket ID |
external_ref | string | New external reference |
design | string | New design notes |
acceptance | string | New 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:
| Param | Type | Description |
|---|---|---|
dir | string | (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:
| Field | Type | Required | Description |
|---|---|---|---|
dir | string | yes | Project directory path |
content | string | yes | Note 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:
- Claims the ticket (
open→in_progress) with file locking - Detects the current branch of the parent project
- Creates a git worktree on branch
tk-<ticket-id> - Creates a thread named after the ticket title
- Sets the ticket’s assignee to the thread name
- Optionally auto-starts an agent with the ticket description
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
dir | string | yes | Project directory path |
channel_id | string | yes | Parent 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" }
}
]
}| Field | Type | Description |
|---|---|---|
req_id | string | Correlation id, same one carried on gate.approval_requested and accepted by POST /api/gate/approvals/{id} |
container_id | string | Docker container id that owns the request — useful for cross-referencing with audit logs |
channel_id | string | Channel the approval is prompting on |
kind | string | Same set as the event payload ("connect", "execve", "file", "docker-http", "docker-body") |
target | string | Human-readable target |
source | string | Origin within the container — "chat" for the entrypoint agent, "terminal:<leafId>" for a specific terminal pane. Omitted when unknown |
message | string | Rule’s message field (omitted when empty) |
details | object | Structured 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:
| Field | Type | Required | Description |
|---|---|---|---|
decision | string | yes | One of "once", "session", or "deny". once lets the current syscall through; session caches the allow for the container lifetime; deny rejects the syscall |
author_id | string | no | Clicking 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:
| Header | Value |
|---|---|
Authorization | Bearer <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-Type | application/json |
Request Body:
| Field | Type | Description |
|---|---|---|
kind | string | "docker-http" / "docker-body" for the proxy; gate-trap categories ("connect", "execve", "file") for the seccomp path |
target | string | Human-readable target of the operation ("GET /containers/json", "/etc/passwd", etc.) |
message | string | Rule’s message field — shown to the user verbatim |
cache_key | string | Key the Manager uses when the user picks “Allow for session” |
Response (200):
{ "decision": "allow", "actor": "u-42", "reason": "cache-hit" }| Field | Type | Description |
|---|---|---|
decision | string | "allow" or "deny" |
actor | string | Clicking user’s id (empty on local desktop) |
reason | string | Free-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:
| Param | Type | Description |
|---|---|---|
dir_path | string | Optional project directory for project-level config merge |
channel_id | string | Optional 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:
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | "add", "update", or "delete" |
scope | string | No | "global" (default) or "project" |
channel_id | string | For project scope | Channel ID to resolve project directory |
workflow | object | For add/update | Full workflow definition (name, description, nodes, inputs) |
name | string | For delete | Workflow 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:
| Field | Type | Required | Description |
|---|---|---|---|
workflow_name | string | yes | Name of the workflow to run |
channel_id | string | no | Channel context for prompt nodes |
dir_path | string | no | Project directory for bash/prompt nodes |
inputs | object | no | Input 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:
| Param | Type | Description |
|---|---|---|
channel_id | string | Optional filter by channel |
limit | int | Max results per page (default 50, capped at 1000) |
offset | int | Number 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:
| Field | Type | Required | Description |
|---|---|---|---|
response | string | no | Response 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.