Skip to content
HTTP API Reference

HTTP API Reference

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

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

General

  • CORS: All responses include Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS, and Access-Control-Allow-Headers: Content-Type. Preflight OPTIONS requests return 204 No Content.
  • Content-Type: JSON endpoints return application/json. File-reading endpoints return text/plain; charset=utf-8.
  • Error responses: Plain text body with the appropriate HTTP status code.

Common Error Codes

CodeMeaning
400Bad request – missing/invalid parameters or request body
404Resource not found
413Request entity too large (file operations)
500Internal server error
501Feature not configured (service dependency is nil)
503Service 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:

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

Response (200):

[
  {
    "channel_id": "abc123",
    "name": "my-project-a1b2",
    "dir_path": "/home/user/projects/my-project",
    "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_running is determined by querying the Docker daemon for running containers.
  • agent_running indicates whether an active Claude agent run exists for the channel.
  • branch is resolved by running git rev-parse --abbrev-ref HEAD in the channel’s directory.
  • commit is the short commit hash from git rev-parse --short HEAD.
  • worktree is true for threads created via POST /api/worktrees.
  • locked is true when the channel/thread is guarded against accidental deletion (toggle via PATCH /api/channels/{id}/lock ). DELETE /api/channels/{id} and DELETE /api/threads/{id} return 409 Conflict while a row is locked.

Errors: 501 if channel listing is not configured.


POST /api/channels

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

Request:

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

Response (200):

{"channel_id": "abc123"}

Behavior notes:

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

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


POST /api/channels/create

Create a new channel with an explicit name.

Request:

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

Response (201):

{"channel_id": "abc123"}

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


POST /api/channels/ensure-all

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

Request:

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

Response (200):

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

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


DELETE /api/channels/{id}

Delete a channel and all its child threads.

Path Parameters:

ParamTypeDescription
idstringChannel ID to delete

Response: 204 No Content

Behavior notes: Deletes child threads (channels with matching parent_id) before deleting the channel itself.

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


PATCH /api/channels/{id}/lock

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

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Request:

{"locked": true}
FieldTypeRequiredDescription
lockedboolyesNew locked state

Response: 204 No Content

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

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


Threads

POST /api/threads

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

Request:

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

Response (201):

{"thread_id": "thread_abc123"}

Behavior notes:

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

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


DELETE /api/threads/{id}

Delete a thread.

Path Parameters:

ParamTypeDescription
idstringThread 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
}
FieldTypeRequiredDescription
channel_idstringyesTarget channel or thread ID
contentstringyesMessage text
modestringnoAgent mode hint (e.g. "plan")
interruptboolnoWhen true, cancels the active run on the channel and inserts this message with priority = MaxQueuedPriority(channel_id) + 1 so it claims next ahead of any queued rows. Existing queued messages are preserved (not deleted). Used by the chat UI’s “Deny with prompt” gate flow.

Response: 204 No Content

Behavior notes:

  • When an IncomingMessageHandler is set, the message is dispatched asynchronously with a detached context (the HTTP response returns immediately).
  • When no handler is set, falls back to direct PostMessage via the configured message sender.
  • interrupt=true requires both a RunCanceller and a Store on the server; the orchestrator wires both during startup.

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


DELETE /api/messages/{id}

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

Path Parameters:

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

Query Parameters:

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

Response: 204 No Content

Behavior notes:

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

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


GET /api/channels/{id}/sessions

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

Path Parameters:

ParamTypeDescription
idstringChannel ID

Response:

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

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:

ParamTypeDescription
idstringChannel ID

Query Parameters:

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

Response (200):

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

Behavior notes:

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

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


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

Remove one audit file from disk.

Path Parameters:

ParamTypeDescription
idstringChannel ID
datestringYYYY-MM-DD

Response: 204 No Content on success.

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


GET /api/channels/{id}/messages

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

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Query Parameters (cursor mode):

ParamTypeDefaultMaxDescription
cursorint640Fetch messages older than this message ID
limitint50200Number of messages to return

Query Parameters (around mode):

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

Response (200):

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

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

Behavior notes:

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

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


GET /api/channels/{id}/queued

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

Path Parameters:

ParamTypeDescription
idstringChannel or thread ID

Response (200):

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

The in-flight message (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:

ParamTypeDescription
idstringChannel or thread ID

Query Parameters:

ParamTypeDefaultMaxDescription
cursor_positionint640Fetch 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_idint640Tiebreaker id for rows that share cursor_position (in particular legacy rows where chain_position = 0).
limitint50200Number of rows to return

Response (200):

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

Behavior notes:

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

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


GET /api/messages/search

Full-text search across all messages using case-insensitive LIKE %query%.

Query Parameters:

ParamTypeDefaultMaxRequiredDescription
qstringyesSearch query
limitint2050noMax results

Response (200):

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

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


Tasks

POST /api/tasks

Create a new scheduled task.

Request:

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

Response (201):

{"id": 1}

GET /api/tasks

List tasks for a channel.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel 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:

ParamTypeDescription
idint64Task ID

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

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


DELETE /api/tasks/{id}

Delete a scheduled task.

Response: 204 No Content

Errors: 400 if ID is invalid.


PATCH /api/tasks/{id}

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

Request:

{
  "enabled": false,
  "schedule": "0 10 * * *",
  "type": "cron",
  "prompt": "Updated prompt",
  "auto_delete_sec": 7200,
  "worktree": true,
  "origin_branch": "develop",
  "update_before_run": true,
  "workflow_name": "validate",
  "workflow_inputs": "{}"
}

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:

ParamTypeDescription
idint64Task ID

Response: 202 Accepted (empty body)

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


GET /api/tasks/{id}/runs

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

Path Parameters:

ParamTypeDescription
idint64Task ID

Response (200):

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

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

Errors: 400 if ID is invalid.


Commands

POST /api/commands

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

Request:

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

Supported commands:

CommandArgumentsDescription
tasksList tasks
statusShow status
readmeShow README
template-listList templates
iamtheownerClaim ownership
tasktask_idShow task
canceltask_idCancel task
toggletask_idToggle task
stop[channel_id]Stop agent
template-addnameAdd template
scheduletype=... schedule=... prompt=...Schedule task
edittask_id [key=value ...]Edit task
allow_usertarget_id [role]Grant access
deny_usertarget_idRevoke access

Response: 204 No Content

Behavior notes:

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

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


Extra Directories

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

GET /api/channels/{id}/roots

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

Path Parameters:

ParamTypeDescription
idstringChannel ID

Response (200):

{
  "roots": [
    "/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:

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

Response (200):

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

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

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


GET /api/channels/{id}/file

Read a file’s contents.

Query Parameters:

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

Response (200):

  • Text files: Content-Type: text/plain; charset=utf-8 with file contents as body.
  • Image files (.png, .jpg/.jpeg, .gif, .webp): Content-Type set to the matching image/* MIME with the raw bytes as the body. No X-File-Binary header — the desktop app uses this endpoint directly as <img src> so the browser handles caching and decoding.
  • Other binary files: Empty body with X-File-Binary: true header and Content-Length set.

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:

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

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

Response (200):

{"ok": true}

Behavior notes: Preserves original file permissions if the file already exists; defaults to 0644 for new files.

Errors: 400 if path is invalid. 413 if content exceeds 5 MB.


DELETE /api/channels/{id}/file

Delete a file or directory.

Query Parameters:

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

Response (200):

{"ok": true}

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

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


POST /api/channels/{id}/dir

Create a directory (including nested intermediate directories).

Query Parameters:

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

Response (200):

{"ok": true}

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

Errors: 400 if path is invalid.


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

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

Request body:

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

Response (200):

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

Behavior notes:

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

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


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

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

Query Parameters:

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

Response (200):

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

Behavior notes:

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

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


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

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

Request body:

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

Response (200):

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

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

Behavior notes:

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

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


Path Validation

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

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

Diff

GET /api/channels/{id}/diff

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

Query Parameters:

ParamTypeDescription
sourcestringWhen provided with target, switches to branch-to-branch diff mode (git diff source..target). status is omitted in this mode.
targetstringBranch / ref name for branch-to-branch diff mode.

Response (200) — uncommitted mode:

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

File entry fields:

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

Behavior notes:

  • Uncommitted mode (default): runs git diff --cached (staged), git diff (unstaged), and git ls-files --others --exclude-standard (untracked). Conflicts are surfaced via git diff --diff-filter=U.
  • The frontend parses staged_diff / unstaged_diff / untracked_diff / conflict_diff independently so the per-bucket status tag survives partial staging (a path that appears in both staged and unstaged buckets would otherwise collide in a single parsed-by-path lookup).
  • Branch-to-branch mode (?source=branchA&target=branchB): a single git diff is returned in diff; the per-status fields and the status tag on each file are omitted.
  • Untracked files generate synthetic diff patches (all lines as additions). Binary detection checks the first 512 bytes for null bytes.
  • If the directory is not a git repo, returns an empty files array.
  • Files are sorted by (path, status priority) — conflict < staged < unstaged < untracked.

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,isDraft and falls back to a gh pr list query when pr view reports no match.
  • The gh account is picked by the github.gh_user config key (mergeable per-project via .loop/config.json); empty falls back to whichever account gh currently has active.
  • Environmental failures (gh not installed, no GitHub remote, network) degrade to present: false rather 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 branches list (git won’t allow switching to them).
  • The main worktree is excluded from the worktrees list.
  • thread_id is populated when the worktree has been imported as a thread (via POST /api/worktrees or POST /api/worktrees/import).

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

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

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

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

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

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

Create and checkout a new branch.

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

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

GET /api/channels/{id}/commits

List commit history for a channel’s git repository.

Query Parameters:

ParamTypeDefaultDescription
branchstringHEADBranch name to list commits from
limitint50Maximum number of commits to return (max 200)
skipint0Number of commits to skip (for pagination)

Response (200):

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

Behavior notes:

  • Commits are returned in reverse chronological order (newest first).
  • On empty repositories (no commits yet), returns {"commits": []} instead of an error.
  • Branch names are validated against a safe character set (alphanumeric, slashes, hyphens, dots, underscores).
  • The skip parameter enables lazy pagination — the frontend loads pages of 50 commits and fetches more on scroll.

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-session works on the first message.
  • The thread’s DirPath points to the worktree directory; Worktree flag is set to true.
  • Container mounts include the parent project directory so git worktree references resolve correctly.

POST /api/worktrees/import

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

Request:

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

Response (201):

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

Behavior notes:

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

DELETE /api/worktrees

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

Request Body:

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

Response: 204 No Content on success.

Behavior notes:

  • Runs git worktree remove --force on the worktree path, then git worktree prune.
  • If thread_id is provided, also deletes the thread record from the database and broadcasts a channel.deleted event.
  • If the git worktree removal fails (e.g. path already gone), returns 500.

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


Quality

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

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

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

Response (202 Accepted):

{ "status": "started" }

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

Errors: 501 if the quality scanner is not configured.


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

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

Response (200):

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

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


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

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

Query Parameters:

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

Response (200):

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

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

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


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

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

Query Parameters:

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

Response (200):

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

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

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


Review

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

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

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

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

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

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

Load a PR’s diff into a local worktree under the channel’s dir_path. 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"
}
FieldTypeRequiredDescription
querystringyesNatural language search query
top_kintnoNumber of results (default: 5)
dir_pathstringno*Project directory for scoping
channel_idstringno*Alternative to dir_path (looked up from DB)

* At least one of dir_path or channel_id is required.

Response (200):

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

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


POST /api/memory/index

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

Request:

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

Response (200):

{"count": 3}

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

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


GET /api/memory/files

List distinct indexed memory file paths for a project.

Query Parameters:

ParamTypeRequiredDescription
dir_pathstringno*Project directory
channel_idstringno*Alternative to dir_path

Response (200):

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

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

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


GET /api/memory/file

Read a memory file’s raw content.

Query Parameters:

ParamTypeRequiredDescription
pathstringyesAbsolute path to a .md file

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

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


Readme

GET /api/readme

Get the Loop project README content.

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


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:

ActionParamsDescription
navigateurlNavigate to a URL
reloadReload the current page
go_backNavigate back in history
go_forwardNavigate forward in history
get_page_infoGet current URL and title
get_element_refsGet accessibility tree elements
mouse_clickx, y, button, click_countClick at coordinates
mouse_movex, yMove mouse
mouse_scrollx, y, delta_x, delta_yScroll
mouse_downx, y, buttonMouse button down
mouse_upx, y, buttonMouse button up
key_presskeyPress a key
type_texttextType text
click_refrefs, ref_indexClick element by ref
screenshotCapture screenshot
evaluate_jsexpressionEvaluate JavaScript
list_tabsList all open tabs
new_taburlOpen a new tab
switch_tabtarget_idSwitch to a tab
close_tabtarget_idClose a tab
resize_windowwidth, heightResize viewport
scroll_into_viewbackend_node_idScroll element into view
read_consolepattern, only_errors, clear, limitRead console messages
read_networkpattern, clear, limitRead network requests

Responses:

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

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


GET /api/ws/browser

WebSocket endpoint for browser screencast streaming and input.

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

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

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


Containers

GET /api/containers

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

Response: 200 OK

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

Errors: 503 if the container registry is not configured.


Agent Registry

GET /api/agents

List active agents for a channel.

Query Parameters:

ParamRequiredDescription
channel_idYesChannel 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"
  }
]
StatusDescription
200JSON array (empty [] if no agents)
400Missing channel_id
503Agent registry not configured

PATCH /api/agents/{id}

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

Request Body:

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

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

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

DELETE /api/agents/{id}

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

Path Parameters:

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

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel ID the agent belongs to

Response: 204 No Content

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

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


POST /api/agents/{id}/message

Send a 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"
}
StatusDescription
204Message delivered
400Missing channel_id or content
404Target agent not found
503Agent 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:

ParamRequiredDescription
agent_idYesAgent ID to subscribe for
channel_idYesChannel 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}"
}
FieldTypeDescription
configobjectParsed config values
rawstringRaw HJSON file contents (for the JSON editor view)

PUT /api/config

Save global config. Accepts raw HJSON text.

Request:

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

Response (200):

{"ok": true}

Errors: 400 if the HJSON is invalid.


GET /api/config/project

Returns the project config for a channel.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel ID to look up the project directory

Response (200):

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

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

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


GET /api/shortcuts

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

Query Parameters:

ParamTypeRequiredDescription
channel_idstringnoChannel ID to merge project-level shortcuts

Response (200):

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

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


POST /api/shortcuts

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

Request Body:

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

Response: 204 No Content on success.

Errors:

  • 400 — missing name, invalid action, missing prompt, mutually exclusive fields, or missing channel_id for project scope
  • 404 — shortcut not found (update/delete)
  • 409 — duplicate name (add)

PUT /api/config/project

Save project config for a channel.

Query Parameters:

ParamTypeRequiredDescription
channel_idstringyesChannel 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:

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

Request:

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

Response: 200 OK

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

GET /api/playground?name=...

Get a named playground’s content.

Query Parameters:

ParamTypeRequiredDescription
namestringyesPlayground name

Response (200):

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

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

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

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

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

Errors: 400 if name is invalid.

GET /api/playground/items

List all playground names from both global and project scopes.

Query Parameters:

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

Response (200):

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

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

GET /api/playground/serve/{name}

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

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

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


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:

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

Response (200):

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

POST /api/tickets

Create a new ticket.

Request Body:

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

Response (201): The created ticket object.


GET /api/tickets/{id}

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

Query Parameters:

ParamTypeDescription
dirstring(required) Project directory path

Response (200): The ticket object.

Errors: 404 if no ticket matches the ID.


PATCH /api/tickets/{id}

Update ticket fields. Only provided fields are modified.

Request Body:

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

Response: 204 No Content on success.

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

Broadcasts ticket.updated WebSocket event.


DELETE /api/tickets/{id}

Delete a ticket.

Query Parameters:

ParamTypeDescription
dirstring(required) Project directory path

Response: 204 No Content on success.

Broadcasts ticket.deleted WebSocket event.


POST /api/tickets/{id}/assign

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

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

Request Body:

FieldTypeRequiredDescription
dirstringyesProject directory path
channel_idstringyesParent channel ID

Response (200):

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

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

Gate Approvals

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

GET /api/gate/approvals

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

Response (200):

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

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

POST /api/gate/approvals/{id}

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

Request Body:

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

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

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

POST /api/gate/container-approval

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

Headers:

HeaderValue
AuthorizationBearer <LOOP_GATE_TOKEN> — the 32-byte-hex bearer the runner minted for this container. Compared in constant time
Content-Typeapplication/json

Request Body:

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

Response (200):

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

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

Workflows

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

GET /api/workflows

List all available workflow definitions from the merged config.

Query Parameters:

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

Response (200):

[
  {
    "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:

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

Response: 204 No Content

Errors: 400 invalid request, 404 workflow not found (update/delete), 409 duplicate name (add).

POST /api/workflows/runs

Start a new workflow run.

Request Body:

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

Response (201):

{
  "run_id": "wfr-a1b2c3d4e5f67890"
}

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

GET /api/workflows/runs

List workflow runs.

Query Parameters:

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

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

Response (200):

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

Errors: 501 if the workflow engine is not configured.

GET /api/workflows/runs/{id}

Get a workflow run with all node statuses and outputs.

Response (200):

{
  "run": {
    "id": "wfr-a1b2c3d4e5f67890",
    "workflow_name": "code-review",
    "status": "completed",
    "inputs": "{\"issue_url\":\"https://...\"}",
    "workflow_def": "{\"name\":\"code-review\",\"nodes\":[...]}",
    "started_at": "2026-04-11T10:00:00Z",
    "finished_at": "2026-04-11T10:02:30Z"
  },
  "node_runs": [
    {
      "run_id": "wfr-a1b2c3d4e5f67890",
      "node_id": "diff",
      "status": "success",
      "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:

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

Response: 204 No Content on success.

Errors: 400 if request body is invalid JSON. 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.