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
- CORS: All responses include
Access-Control-Allow-Origin: *,Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS, andAccess-Control-Allow-Headers: Content-Type. PreflightOPTIONSrequests return204 No Content. - 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 |
| 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) |
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",
"parent_id": "",
"active": true,
"container_running": true,
"agent_running": false,
"branch": "main",
"commit": "abc1234",
"worktree": false,
"locked": false
}
]Behavior notes:
- When a channel has no
dir_path, falls back to~/.loop/{channel_id}/work. container_runningis determined by querying the Docker daemon for running containers.agent_runningindicates whether an active Claude agent run exists for the channel.branchis resolved by runninggit rev-parse --abbrev-ref HEADin the channel’s directory.commitis the short commit hash fromgit rev-parse --short HEAD.worktreeis true for threads created viaPOST /api/worktrees.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.
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.
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.
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.
DELETE /api/threads/{id}
Delete a thread.
Path Parameters:
| Param | Type | Description |
|---|---|---|
id | string | Thread ID to delete |
Response: 204 No Content
Behavior notes: Removes the thread’s MCP config file, deletes from the chat platform (if a creator is configured), and removes from the database. 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
}| 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. |
Response: 204 No Content
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.
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— bot replies and already-processed history 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 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..."
}
]
}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.
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.
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, 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 (the one with is_running = 1 on the row) is included in the response — clients use the agent.status
event’s msg_id to distinguish “processing” from “queued”. Higher priority values sort first; priority is omitted when zero.
Errors: 501 if message listing is not configured. 500 on database error.
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%.
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.
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 is set) |
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 |
Response (201):
{"id": 1}GET /api/tasks
List tasks for a channel.
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel ID |
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.
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": "{}"
}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 clear it 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.
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": [
"/home/user/projects/my-project",
"/home/user/projects/shared-lib",
"/home/user/projects/proto"
]
}The first entry is always the primary dir_path. Subsequent entries are the extra directories in the order they were set.
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) |
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. - Other binary files: Empty body with
X-File-Binary: trueheader andContent-Lengthset.
Behavior notes:
- The image branch is matched on extension and runs 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. - Path validation rejects absolute paths,
..traversal, and symlink escapes.
Errors: 400 if path is invalid. 404 if file not found. 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 0644 for new files.
Errors: 400 if path is invalid. 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.
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. - 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. |
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. - 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.
Errors: 404 if channel not found.
Pull Request
GET /api/channels/{id}/pr
Look up the open GitHub pull request whose head branch matches the channel’s current branch. Shells out to gh pr view 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
}
}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. - Environmental failures (
ghnot installed, no GitHub remote, network) degrade topresent: falserather than a 5xx so the UI hides the PR chip silently.
Errors: 404 if channel not found.
Branches & Worktrees
GET /api/channels/{id}/branches
List local git branches and worktrees for a channel’s directory.
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).
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) |
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.
Errors: 400 if branch name is invalid.
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. - 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.
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.
GET /api/channels/{id}/review
Return the channel’s review session, or {"present": false} if none.
DELETE /api/channels/{id}/review
Remove the channel’s session and delete the on-disk worktree. 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.
Errors: 404 if no session. 409 if the session is not in ready status. 501 if the review agent is not wired.
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 in-memory 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. Errors are accumulated rather than short-circuiting — one bad comment does not block the rest.
Response: {"pushed": N, "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.
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.
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 accessibility tree 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 | Switch to a tab |
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.
Agent Registry
GET /api/agents
List active agents for a channel.
Query Parameters:
| Param | Required | Description |
|---|---|---|
channel_id | Yes | Channel ID to list agents for |
Response: JSON array of AgentInfo objects.
[
{
"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 |
| 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 push message to an agent’s mailbox.
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 delivered |
| 400 | Missing channel_id or content |
| 404 | Target agent not found |
| 503 | Agent registry not configured |
Messages are non-blocking — dropped if the target’s mailbox (buffer size 64) is full.
GET /api/ws/agent-channel
WebSocket endpoint for MCP servers to receive pushed messages.
Query Parameters:
| Param | Required | Description |
|---|---|---|
agent_id | Yes | Agent ID to subscribe for |
channel_id | Yes | Channel ID |
Messages are forwarded as JSON:
{
"from_agent_id": "docker-agent-0",
"content": "task completed",
"timestamp": "2026-03-25T10:05:00Z"
}The WebSocket closes when the agent is unregistered (terminal session closed).
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, or missing channel_id for project scope404— shortcut not found (update/delete)409— duplicate name (add)
PUT /api/config/project
Save project config for a channel.
Query Parameters:
| Param | Type | Required | Description |
|---|---|---|---|
channel_id | string | yes | Channel ID |
Request:
{
"raw": "{\n \"claude_model\": \"claude-opus-4-6\"\n}"
}Response (200):
{"ok": true}Creates the .loop/ directory and config file if they don’t exist.
Errors: 400 if channel_id is missing or HJSON is invalid. 404 if channel not found.
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.
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/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.
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 project directory path.
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",
"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}/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 <LOOP_GATE_TOKEN> — the 32-byte-hex bearer the runner minted for this container. 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):
[
{
"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.
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, 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",
"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"
}
]
}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. 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: 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: 500 on engine errors (run not found, 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: 500 on engine errors. 501 if the workflow engine is not configured.