Real-Time Events System
The events system provides real-time delivery of server events to connected clients via WebSocket at GET /api/ws. Clients subscribe to specific channels and receive JSON-encoded events for message creation, agent status changes, tool usage, and channel lifecycle.
Related docs: HTTP API | Terminal WebSocket | Memory System
EventsHub Architecture
The EventsHub is the central event dispatcher. It maintains a set of WebSocket subscribers and broadcasts events to those whose channel filter matches.
Event source (orchestrator, bot, agent)
│
▼
EventsHub.Broadcast(Event)
│
├──> subscriber 1 (channels: {chan_a, chan_b}) ──> match? ──> write
├──> subscriber 2 (channels: {}) ──> all ──> write
└──> subscriber 3 (channels: {chan_c}) ──> skipThread Safety
The hub uses a sync.RWMutex (mu) to protect the subscriber set:
- Registration/unregistration: Acquires write lock (
Lock). - Broadcast: Acquires read lock (
RLock) to snapshot the subscriber list, releases it, then iterates the snapshot to write to each connection.
Each connection has its own sync.Mutex (writeMu) that serializes writes to that specific WebSocket. This means:
- A concurrent
Unregistermay remove a subscriber thatBroadcastis about to write to. This is safe because the connection’swriteMustill guards the write. - A failed write triggers
Unregisterfor that client, which is idempotent (map delete).
WebSocket Connection
Endpoint
GET /api/wsQuery Parameters:
| Param | Type | Description |
|---|---|---|
channels | string | Comma-separated channel IDs to subscribe to initially |
Example: GET /api/ws?channels=chan_a,chan_b
Errors: 501 if the events hub is not configured.
The WebSocket upgrader accepts all origins (CheckOrigin returns true).
Subscription Model
Initial Subscription
Clients can subscribe to channels in two ways:
- Query parameter: Pass
?channels=id1,id2on the WebSocket URL. - Subscribe message: Send a JSON message after connecting.
Subscribe Message (Client to Server)
{
"type": "subscribe",
"channels": ["chan_a", "chan_b", "chan_c"]
}| Field | Type | Description |
|---|---|---|
type | string | Must be "subscribe" |
channels | string[] | Channel IDs to subscribe to; replaces the previous subscription |
Behavior:
- Sending a
subscribemessage replaces the current channel filter entirely (it does not merge). - An empty
channelsarray subscribes to all events (no filtering). - The subscription update acquires the connection’s
writeMuto avoid races with concurrent broadcasts.
Channel Filter Semantics
channels state | Behavior |
|---|---|
| Empty map (or nil) | Receives events for all channels |
| Non-empty map | Receives events only for channels in the map |
Event Types
All events share a common envelope:
{
"type": "message.created",
"channel_id": "chan_a",
"data": { ... },
"timestamp": 1709337600000
}| Field | Type | Description |
|---|---|---|
type | string | Event type identifier |
channel_id | string | Channel the event belongs to |
data | object | Type-specific payload |
timestamp | int64 | Unix milliseconds when the event was broadcast |
message.created
A new message was posted to a channel (by a user or the bot).
Payload schema:
{
"msg_id": "discord_msg_id_or_internal_id",
"author_id": "user123",
"author_name": "Alice",
"content": "Hello, world!",
"is_bot": false,
"is_processed": false,
"priority": 0,
"trigger_msg_id": "user_msg_uuid",
"not_before": 0
}| Field | Type | Description |
|---|---|---|
msg_id | string | Platform-specific message ID |
author_id | string | Author’s platform user ID |
author_name | string | Author’s display name |
content | string | Full message content |
is_bot | bool | Whether the message was sent by the bot |
is_processed | bool | Whether the row has already been processed by the orchestrator. Replayed message.created events (e.g. after a reconnect) carry the persisted value so the FE renders historical user rows without a “queued” label. |
priority | int | Queue priority (omitted when zero). Higher values run ahead of older queued rows; used by deny-with-prompt interrupts to insert ahead of B/C without deleting them. The FE renders the position as 1/N in the queued chip. |
trigger_msg_id | string | For bot replies, the msg_id of the user message whose agent run produced this row. A turn a live session
started by itself links its rows to the remote prompt it answers, or else to the turn before it, which it carries on. Omitted on user messages and pre-feature bot rows. The FE uses it to group the reply (and the run’s intermediate tool.use/agent.thinking rows, which carry the same trigger_msg_id when persisted to the timeline) under the triggering user message — needed because priority-bumped runs can complete out of chronological order. |
not_before | int64 | Unix-seconds timestamp before which a delayed message (queued via queue_message with delay_seconds) runs. Omitted when zero (immediate). Carried on the event so the FE bubble can render the live countdown on live insert, not only after a reload. |
forkable | bool | Set on a bot reply that records its place in the session’s transcript, so the conversation can be forked at it . Omitted otherwise. |
message.streaming
Partial bot response during streaming. The client should update the in-progress message display.
Payload schema:
{
"content": "Here is the partial response so far..."
}| Field | Type | Description |
|---|---|---|
content | string | Accumulated response text so far |
message.deleted
A queued user message was removed from the channel’s message queue via DELETE /api/messages/{id}. Emitted only when the delete actually removed a row (the query is scoped to is_bot = 0 AND is_processed = 0 AND is_running = 0, so bot replies, already-processed history and a claimed message can never trigger it).
Payload schema:
{
"msg_id": "discord_msg_id_or_internal_id"
}| Field | Type | Description |
|---|---|---|
msg_id | string | Platform-specific message ID of the deleted message |
message.updated
A queued user message’s text was edited via PUT /api/channels/{id}/queued/{msg_id} before it started. Emitted only when the edit was applied.
Payload schema:
{
"msg_id": "ask-1a2b3c4d5e6f7a8b",
"content": "the edited prompt"
}| Field | Type | Description |
|---|---|---|
msg_id | string | Message ID of the edited message |
content | string | The new message text |
agent.status
Agent lifecycle status change (running, completed, errored). Global: every client receives it whatever channels it subscribed to, so the sidebar shows a run that started outside the open channel (another agent’s send_message, a chat platform, a scheduled task) as soon as it starts. The desktop app then subscribes to that channel for the run’s remaining events.
Payload schema:
{
"status": "completed",
"run_id": "a1b2c3d4e5f67890",
"duration_ms": 12345,
"num_turns": 3,
"stop_reason": "end_turn",
"model": "claude-sonnet-4-20250514",
"trigger_content": "hi",
"msg_id": "discord_msg_id_or_internal_id",
"thread_id": "thread_abc123"
}| Field | Type | Description |
|---|---|---|
status | string | "running", "completed", or "error" |
run_id | string | Unique identifier for this agent run. The frontend uses this to distinguish concurrent runs on the same channel (e.g. a scheduled task completing should not clear the running indicator for a parallel chat agent). |
error | string | Error message (only when status is "error") |
duration_ms | int | Total run duration in milliseconds (on completion) |
num_turns | int | Number of conversation turns (on completion) |
stop_reason | string | Why the agent stopped (e.g., "end_turn", "max_turns") |
model | string | Model used for the run |
trigger_content | string | Content of the message that triggered the run (on "running" status) |
msg_id | string | msg_id of the user message that triggered the run. Present on running, completed, and error for the same row. The frontend uses it to label the correct chat bubble as “processing” — needed because priority-bumped messages (deny-with-prompt interrupts) can be processed ahead of older queued rows, so the FE cannot infer it from array position. On running of a turn a live session
started by itself (trigger "live"), it’s the message of the turn the session carries on, the one its replies link to (see trigger_msg_id on message.created
), and on its completed and error it’s omitted. |
trigger | string | What started the run: "scheduled" for a scheduled task, "learn" for a learn pass
, "learn-reply" for a message the user sent in a learn thread, "explain" for an explanation
, "bot" for a message the bot itself posted (an agent using send_message / create_thread), omitted for a user’s message anywhere else. The desktop app doesn’t bounce the dock for scheduled, bot, learn or explain runs, and a learn or explain run doesn’t mark its channel unread or post a notification. |
stopped | bool | Set on an "error" the user caused by stopping the run, with Stop or by sending a message that interrupted it. Loop posts no notification for it. |
thread_id | string | Thread ID for scheduled task runs. Present on all status events (running, error, completed) when the task has an existing thread. The frontend uses this to route state (store entry, isRunningMap) to the thread instead of the parent channel, so the parent doesn’t show a running indicator for thread work and the thread view shows the stop button and streaming content. |
tool.use
The agent is invoking a tool.
Payload schema:
{
"tool_use_id": "toolu_017fNc...",
"tool_name": "Bash",
"input": "ls -la"
}| Field | Type | Description |
|---|---|---|
tool_use_id | string | Per-block id from the assistant message. Pairs this event with the eventual tool.result event carrying the same id, and with the matching tool_use / tool_result rows returned by /api/channels/{id}/timeline
. |
tool_name | string | Name of the tool being called |
input | string | Tool input (may be truncated for display) |
agent.thinking
The agent emitted an extended-thinking block. Mirrors the persisted kind: "thinking" row served by /timeline, so live tail and reload render the same content.
Payload schema:
{
"text": "Let me check how the existing tests cover this path..."
}| Field | Type | Description |
|---|---|---|
text | string | The thinking block’s text. Already truncated at 8 KiB inline; the same value is persisted on the corresponding kind: "thinking" row. |
tool.result
A tool call finished. Pairs with the prior tool.use event by tool_use_id. Mirrors the persisted kind: "tool_result" row served by /timeline.
Payload schema:
{
"tool_use_id": "toolu_017fNc...",
"output": "package api\n\n// ...\n",
"is_error": false
}| Field | Type | Description |
|---|---|---|
tool_use_id | string | Pairs this event with the originating tool.use event (and with the persisted timeline rows). |
output | string | Tool output, already truncated at 8 KiB by the runner. |
is_error | bool | true when the tool reported a failure. |
agent.activity
Agent activity indicator for UI status displays. Covers model detection, subagent progress, and other activities.
Payload schema:
{
"activity": "model",
"model": "claude-sonnet-4-20250514",
"description": ""
}| Field | Type | Description |
|---|---|---|
activity | string | Activity type: "model", "subagent_started", "subagent_progress", "background_tasks" and others |
model | string | Model name (when activity is "model") |
description | string | Human-readable description of the activity |
tasks | array | The background tasks, on a background_tasks activity |
A background_tasks activity is sent each time the set of tasks running in the background changes, mid-turn too. Its description lists them ("2 background tasks: watch CI, build image"), and is empty once none are left. tasks holds every task still running, and is left out once none are:
{
"activity": "background_tasks",
"description": "1 background task: watch CI",
"tasks": [{ "id": "b1x2", "type": "local_bash", "description": "watch CI", "started_at": "2026-01-02T15:04:05Z" }]
}| Task field | Type | Description |
|---|---|---|
id | string | Claude’s task id, which stops it in a live session |
type | string | Claude’s task type: local_bash (a shell command), monitor (Claude’s Monitor tool), local_agent, remote_agent, local_workflow and others |
description | string | What the task does |
started_at | string | When Loop first saw it running. Claude reports no start time, so a foreground command sent to the background starts then |
agent.ask_user
Claude used the AskUserQuestion tool to ask structured questions. The desktop app renders these as interactive cards with clickable option buttons.
Payload schema:
{
"questions": [
{
"question": "Where should the file be created?",
"header": "Location",
"options": [
{ "label": "Root directory", "description": "Create in the project root" },
{ "label": "src/ folder", "description": "Create inside the src directory", "preview": "src/\n new-file.ts" }
],
"multiSelect": false
}
]
}| Field | Type | Description |
|---|---|---|
questions | array | List of questions to present (1–4 per call) |
questions[].question | string | The question text |
questions[].header | string | Short header/label for the question, rendered as a chip |
questions[].options | array | Selectable options (label, description, optional preview) |
questions[].options[].preview | string | Optional mockup / code snippet shown in a panel when the option is hovered or focused |
questions[].multiSelect | bool | Whether the question is a checkbox list (multiple options selectable) |
The field names mirror Claude Code’s tool-input schema (camelCase) — the orchestrator unmarshals the raw tool_use input directly into this payload.
The card stays visible until the ask is resolved (it is not cleared when another run starts). The user’s answers are submitted via POST /api/channels/{id}/ask/resolve, which clears the park, emits agent.ask_resolved
, and inserts the answers as a priority-bumped continuation message. Messages queued while the card is up wait behind the ask. An implicit “Other” free-text option is always available.
agent.exit_plan
Claude used the ExitPlanMode tool to signal that a plan is ready for review. The desktop app renders this as a plan preview card with “Accept & Execute” and “Request Changes” buttons.
Payload schema:
{
"plan": "# My Plan\n\n## Steps\n1. Do this\n2. Do that",
"planFilePath": "/path/to/plan.md"
}| Field | Type | Description |
|---|---|---|
plan | string | The full plan content (markdown) |
planFilePath | string | Path where the plan file was written |
Clicking “Accept & Execute” switches mode from plan to agent and sends an approval message. Clicking “Request Changes” keeps plan mode and sends a revision request.
agent.ask_resolved
The channel’s pending AskUserQuestion park was cleared (the user answered or cancelled via POST /api/channels/{id}/ask/resolve). The frontend drops the ask card on this event — and only on this event, so a sibling run starting while the channel is parked cannot hide a still-pending question.
Payload: none (the channel_id on the envelope identifies the ask).
agent.plan_resolved
The channel’s pending ExitPlanMode park was cleared (approved or denied via POST /api/channels/{id}/plan/resolve). Mirrors agent.ask_resolved for the plan card.
Payload: none.
channel.created
A new channel or thread was created. Sent to the parent channel so subscribers can update their channel/thread list.
Payload schema:
{
"channel_id": "new_thread_id"
}| Field | Type | Description |
|---|---|---|
channel_id | string | ID of the newly created channel or thread |
playground.update
A named playground was updated (by agent via MCP tool or API). This event is broadcast globally (no channel scoping).
Payload schema:
{
"name": "snake-game",
"html": "<div id='app'>...</div>",
"css": "body { margin: 0; }",
"js": "console.log('hello')",
"import_map": "{\"imports\":{}}",
"description": "Added score counter"
}The frontend PlaygroundPanel listens for this event. If the event’s name matches the active playground, the iframe hot-reloads. If it’s a new name, the panel auto-switches to it.
channel.deleted
A channel was deleted.
Payload schema: null (no data field, or omitted).
The channel_id in the event envelope identifies which channel was deleted.
channel.updated
A channel’s git state changed (branch switch, new commit, or diff size). Broadcast globally so the sidebar branch label, diff counts, and any open Git panel can react without re-fetching /api/channels.
Emitted by the in-process BranchPoller goroutine, which ticks every 5s (default), compares each channel’s working directory against the previous tick, and broadcasts only when at least one field changed. The first tick after startup primes the cache without broadcasting; a channel first seen on a later tick (created since) is broadcast once, since its git state may have moved on since the sidebar fetched it.
Also emitted by POST /api/channels/{id}/rename
, carrying the new name, by POST /api/channels/{id}/description
, carrying the new description (empty when cleared), and by POST /api/channels/{id}/ticket
, carrying the new ticket_url (empty when cleared). Those events carry nothing else, so a client applies only the name, description or ticket URL and keeps the git fields it has.
Payload schema:
{
"channel_id": "channel-id",
"branch": "feat/git-panel-pr-aware",
"commit": "dbd4bbd",
"diff_additions": 1477,
"diff_deletions": 6
}| Field | Type | Description |
|---|---|---|
channel_id | string | Channel whose state changed |
branch | string | Current branch (git rev-parse --abbrev-ref HEAD) |
commit | string | Short commit hash (git rev-parse --short HEAD) |
diff_additions | int | Lines added in the working-tree diff |
diff_deletions | int | Lines removed in the working-tree diff |
subject | string | The commit’s subject line |
upstream | string | Tracking branch, e.g. origin/main (omitted when there’s none) |
ahead / behind | int | Commits the branch is ahead of / behind upstream |
sync_base | string | A worktree thread’s base branch, when it still resolves |
base_ahead / base_behind | int | Commits the checkout is ahead of / behind sync_base |
name | string | New display name (only on rename; omitted otherwise) |
description | string | New description, empty when cleared (only on a description change; omitted otherwise) |
ticket_url | string | New ticket URL, empty when cleared (only on a ticket change; omitted otherwise) |
channel.pr
The status of a thread’s PR/MR changed: its checks, an auto-fix sent or stopped, or a merge armed or done (see pr.md
). Broadcast globally, and only when something changed, so the sidebar’s ci / ci…, cnf and cmt pills and the Git panel’s PR/MR tab update without re-fetching. An event with only channel_id means the branch no longer has a PR/MR.
Payload schema:
{
"channel_id": "thread-id",
"kind": "pr",
"number": 342,
"url": "https://github.com/example/repo/pull/342",
"head_sha": "4f1c2d9e8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e",
"base_ref": "main",
"checks": {"total": 7, "passed": 6, "failed": 1, "pending": 0},
"failed": [{"id": "123", "name": "lint", "group": "CI", "url": "https://github.com/example/repo/actions/runs/9/job/123", "run_id": "9"}],
"merge_state": "blocked",
"auto_merge": false,
"pr_attention": "failed",
"auto_fix": false,
"auto_fix_default": true,
"attempts": 0,
"max_attempts": 3,
"reason": "not_agent_commit",
"checkout_sha": "4f1c2d9e8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e",
"unresolved_threads": 1,
"alert": "PR #342: lint failed"
}| Field | Type | Description |
|---|---|---|
channel_id | string | The thread |
kind | string | pr (GitHub) or mr (GitLab) |
number | int | The PR number or MR IID |
url | string | The PR/MR’s web URL |
head_sha | string | The head commit the checks ran on |
base_ref | string | The branch the PR/MR merges into, omitted when unknown |
checks | object | total, passed, failed and pending checks; a cancelled or skipped one counts only toward total |
failed | array | The failed checks: id, name, group (the workflow or stage), url, run_id, and infra for a GitLab runner failure |
merge_state | string | clean, blocked, behind, conflict, draft or unknown |
auto_merge | bool | The forge’s auto-merge is on |
merged, closed | bool | The PR/MR was merged or closed |
merge_armed | string | The method “merge when ready” was armed with; omitted when not armed |
merge_methods | array | The methods the repo allows; omitted once merged or closed |
merge_method | string | The method a merge would use now |
pr_attention | string | failed (lights the ci pill), fixing (ci…), conflict (cnf) or empty |
auto_fix | bool | The thread’s effective auto-fix setting |
auto_fix_default | bool | The config’s pr.auto_fix |
attempts | int | Fixes sent for the current failure streak |
max_attempts | int | The config’s pr.max_attempts |
reason | string | Why the watcher last acted as it did on the failure: not_agent_commit, auto_fix_off, max_attempts, no_new_commit, same_failure, infra_rerun, infra_again, fix_sent or fix_asked (see pr.md
); omitted when the checks pass or a new head’s checks start running |
checkout_sha | string | The commit the thread’s checkout is at; omitted when it can’t be read |
unresolved_threads | int | The PR/MR’s unresolved review threads; omitted when none (see pr.md ) |
alert | string | A new failure that needs the owner (a merge conflict and review threads show in the sidebar and PR/MR tab only). It also adds a notification, so it’s set on one event only |
channel.agent_config
A channel’s model/effort overrides changed via PATCH /api/channels/{id}/agent-config
. Broadcast globally so the sidebar’s row info shows them without re-fetching /api/channels.
Payload schema:
{
"model_override": "claude-opus-5-5",
"effort_override": "high"
}| Field | Type | Description |
|---|---|---|
model_override | string | The channel’s model, or empty when it inherits the config’s |
effort_override | string | The channel’s effort, or empty when it inherits the config’s |
channel.learn
A channel’s learn switch changed via PUT /api/channels/{id}/learn
. channel_id is that channel. Every window’s composer updates its Learn switch from it.
Payload schema:
{ "learn": "on" }| Field | Type | Description |
|---|---|---|
learn | string | "on", "off", or empty when the channel now inherits learn.enabled |
Scope: Global.
learn.started
A learn pass
started for a channel. channel_id is the channel being learned from. Global because the learn thread is hidden: no client is subscribed to it until it hears the thread’s id. The desktop app then subscribes to the learn thread to follow the run’s agent.status.
Payload schema:
{ "learn_channel_id": "learn-a1b2c3d4e5f6" }| Field | Type | Description |
|---|---|---|
learn_channel_id | string | The channel’s hidden learn thread, where the pass runs |
Scope: Global.
learn.proposals
A learn pass filed proposals, withdrew earlier ones, or both (via POST /api/channels/{id}/learn/proposals
). channel_id is the channel being learned from. Every window merges both lists into its Learn view by id, so a withdrawn card updates in place.
Payload schema:
{
"proposals": [ { "id": 12, "kind": "bash_shortcut", "title": "Add a make lint bash shortcut", "status": "pending", "...": "..." } ],
"withdrawn": [ { "id": 9, "status": "withdrawn", "withdrawn_reason": "replaced by a newer proposal", "...": "..." } ]
}| Field | Type | Description |
|---|---|---|
proposals | array | The stored proposals, shaped as in GET /api/channels/{id}/learn/proposals
, each with the message_id of the turn its pass reviews; [] when the pass only withdrew |
withdrawn | array | The proposals it withdrew, same shape, with status: "withdrawn" and withdrawn_reason; omitted when none |
Scope: Global.
learn.pass
A learn pass
, automatic or asked for
, was queued, started, finished or failed. channel_id is the channel being learned from; data is the whole pass with its new status, shaped as in GET /api/channels/{id}/learn/passes
. The desktop app shows it at the end of the turn it reviews (message_id). Global because the learn thread doing the work is hidden.
Payload schema:
{
"id": 5,
"channel_id": "abc123",
"message_id": "loop-msg-17",
"learn_channel_id": "learn-a1b2c3d4e5f6",
"status": "running",
"created_at": "2026-03-25T14:30:00Z",
"updated_at": "2026-03-25T14:30:02Z"
}| Field | Type | Description |
|---|---|---|
status | string | queued, running, done or failed (with error). Loop no longer sends superseded; only passes from before passes queued one after another have it, in the list endpoint. |
Only the list endpoint fills in message_row_id.
Scope: Global.
learn.proposal_updated
A proposal was applied, failed to apply, or was dismissed. channel_id is the channel the proposal belongs to; data is the whole proposal with its new status (applied, failed with error, or dismissed), shaped as in GET /api/channels/{id}/learn/proposals
.
Scope: Global.
channel.explain
A channel’s explain switch changed via PUT /api/channels/{id}/explain
. channel_id is that channel. Every window’s composer updates its Explain switch from it.
Payload schema:
{ "explain": "on" }| Field | Type | Description |
|---|---|---|
explain | string | "on", "off", or empty when the channel now inherits explain.enabled |
Scope: Global.
explain.updated
An explanation
was queued, started, finished or failed. channel_id is the explained channel; data is the whole explanation with its new status (queued, running, done with content, or failed with error), shaped as in GET /api/channels/{id}/explanations
. Only the event of a newly queued one carries message_row_id, prompt and reply; the desktop app keeps them from earlier. Global because the explain thread doing the work is hidden.
Scope: Global.
live.status
A channel’s live session started, changed or ended. The chat’s composer footer renders from it.
Payload schema:
{
"running": true,
"channel_id": "ch1",
"started_at": "2026-01-02T15:04:05Z",
"session_id": "0a1b2c3d-…",
"busy": false,
"background": "1 background task: watch CI",
"background_tasks": [{ "id": "b1x2", "type": "local_bash", "description": "watch CI", "started_at": "2026-01-02T15:04:05Z" }]
}| Field | Type | Description |
|---|---|---|
running | bool | Whether a live session is up for the channel |
started_at | string | When the session started (while running) |
session_id | string | The Claude session it runs (while running) |
busy | bool | Whether a turn is in progress |
background | string | The background tasks (shells, subagents, monitors) it still waits on, empty when there are none |
background_tasks | array | Those tasks, shaped as an agent.activity
’s tasks; left out when there are none |
reason | string | Why it ended (when not running): idle, config changed, session changed, scheduled run, one-shot run, stopped, closed, deleted, shutdown, stdin closed, interrupt timed out, exited, exited: …, restart failed: … |
A turn the session starts on its own, after a background task wakes it, runs with agent.status trigger: "live".
Scope: Channel.
agent.prompt_suggestion
The agent predicted the next prompt the user will send, after a chat turn’s result. Only app chats ask for one, and only while claude_prompt_suggestions
is on; see prompt suggestions
. Unlike agent.activity, it isn’t a sign that a run is going: it arrives after the turn ended.
Payload schema:
{ "suggestion": "run the tests" }Scope: Channel.
monitor.started / monitor.finished
A monitor
started or ended in the channel. monitor.started carries the monitor, shaped as in GET /api/channels/{id}/monitors
; monitor.finished carries how it ended. The chat lists running monitors above the composer from these.
Payload schema (monitor.finished):
{ "id": "mon-1a2b3c4d", "channel_id": "ch1", "status": "exited with status 0" }| Field | Type | Description |
|---|---|---|
status | string | exited with status N, timed out after <duration>, cancelled, or the error that stopped the container |
Scope: Channel.
container.registered
A new container was added to the registry (agent started, shell created, Chrome launched).
Payload schema:
{
"container_id": "abc123def456",
"channel_id": "chan_a",
"type": "agent",
"status": "running",
"container_name": "loop-my-project-a1b2c3"
}| Field | Type | Description |
|---|---|---|
container_id | string | Docker container ID |
channel_id | string | Channel the container belongs to |
type | string | Container type: "agent", "shell", or "chrome" |
status | string | Lifecycle status (always "running" on registration) |
container_name | string | Docker container name |
Scope: Global (no channel filtering — broadcast to all subscribers).
container.status_changed
A container’s lifecycle status changed (e.g. running → stopped, stopped → pending-removal).
Payload schema:
{
"container_id": "abc123def456",
"channel_id": "chan_a",
"type": "agent",
"status": "pending-removal",
"container_name": "loop-my-project-a1b2c3",
"remove_at": "2026-03-31T12:05:00Z"
}| Field | Type | Description |
|---|---|---|
container_id | string | Docker container ID |
channel_id | string | Channel the container belongs to |
type | string | Container type |
status | string | New status: "running", "stopped", or "pending-removal" |
container_name | string | Docker container name |
remove_at | string? | ISO 8601 timestamp when removal is scheduled (only for pending-removal) |
Scope: Global.
container.removed
A container was unregistered from the registry (removed from Docker or reconciled away).
Payload schema:
{
"container_id": "abc123def456",
"channel_id": "chan_a",
"type": "agent",
"status": "pending-removal",
"container_name": "loop-my-project-a1b2c3"
}| Field | Type | Description |
|---|---|---|
container_id | string | Docker container ID |
channel_id | string | Channel the container belonged to |
type | string | Container type |
status | string | Status at time of removal |
container_name | string | Docker container name |
Scope: Global.
ticket.created / ticket.updated / ticket.deleted
Ticket lifecycle events from the Tickets API . The Kanban panel subscribes to these to refresh the board.
Payload schema:
{
"ticket_id": "tic-a1b2c3d4"
}| Field | Type | Description |
|---|---|---|
ticket_id | string | The ticket ID that was affected |
Scope: Global (no channel filtering).
workflow.run_started / workflow.run_completed / workflow.run_paused
Workflow run lifecycle events. Broadcast when a workflow begins execution, when it reaches a terminal state (completed, failed, or cancelled), or when an approval node pauses the run for human input.
Payload schema:
{
"run_id": "wfr-a1b2c3d4e5f67890",
"workflow_name": "code-review",
"channel_id": "chan_a",
"status": "paused",
"paused_node_id": "approve",
"error": ""
}| Field | Type | Description |
|---|---|---|
run_id | string | Workflow run ID |
workflow_name | string | Name of the workflow definition |
channel_id | string | Channel context (may be empty) |
status | string | "running", "paused", "completed", "failed", or "cancelled" |
paused_node_id | string | Node ID that caused the pause (only on "paused" status) |
error | string | Error message (only on "failed" status) |
Scope: Global.
workflow.node_started / workflow.node_completed
Individual node lifecycle events within a workflow run.
Payload schema:
{
"run_id": "wfr-a1b2c3d4e5f67890",
"node_id": "diff",
"status": "success",
"input": "git diff main...HEAD",
"session_id": "",
"output": "+added line"
}| Field | Type | Description |
|---|---|---|
run_id | string | Parent workflow run ID |
node_id | string | Node identifier within the workflow |
status | string | "running", "success", "failed", or "skipped" |
input | string | Node’s rendered input — bash script / resolved prompt (truncated to 1000 chars, only on completion) |
session_id | string | Prompt node’s Claude session id (empty for non-prompt nodes) |
output | string | Node output text (truncated to 1000 chars, only on completion) |
iteration | integer | Loop iteration index when the node is a child of a loop body (omitted / 0 for nodes outside a loop). |
Scope: Global.
gate.approval_requested
A seccomp gate trap matched a rule with decision: approve and is blocked waiting for a human decision. The UI should render an approval card and POST the outcome to POST /api/gate/approvals/{id}
.
Payload schema:
{
"req_id": "gate-req-8f1c...",
"kind": "docker-http",
"target": "POST /containers/abc123/exec",
"message": "agent wants to exec into a container",
"details": {
"cmd": "bash, -c, whoami",
"user": "root",
"privileged": "true"
}
}| Field | Type | Description |
|---|---|---|
req_id | string | Gate-server-assigned correlation id; echoed back on the resolve POST |
kind | string | "connect", "execve", or "docker-http" — selects how the UI renders the target |
target | string | Human-readable summary (socket path, command line, or METHOD /path) |
message | string | Matching rule’s message field, shown as extra context |
details | object (optional) | Structured key/value summary of the request body for docker-http prompts on /containers/create, /containers/{id}/exec, /networks/create, and /volumes/create. Renderers should sort keys for stable display. See Gates: Body details surfaced in the prompt
for the per-endpoint key set. file prompts from the git guard
carry a single diff key: a unified diff of the content the gate will write, meant to be rendered as a diff rather than a key: value line. Reviewed renames onto an approve path (Approve rules show the diff of a rename
) carry diff and source, the rename’s source path. Its pointer prompts carry executable and command (the writing process’s command line, with unusual arguments quoted) instead |
Scope: Channel (the container’s channel).
quality.session_started
Emitted at the start of a quality scan. Tells the UI to dim the previous snapshot and show the in-progress indicator.
Payload schema:
{
"dir_path": "/work",
"branch": "main"
}Scope: Channel.
quality.scanned
Emitted when a scan completes. Carries the full QualityScanReport — the same shape returned by POST /api/channels/{id}/quality/scan and GET /api/channels/{id}/quality/snapshot. The panel re-renders headline signal, metric cards, treemap, and rules from this payload.
Payload schema: QualityScanReport — see Quality
and HTTP API: Quality
.
Scope: Channel.
quality.rules_violated
Emitted alongside quality.scanned only when at least one rule failed. Carries the same QualityRulesReport ({passed, failed}) embedded in the report — separated as its own event so notification surfaces (toasts, badges) can subscribe without having to inspect the full report.
Payload schema:
{
"passed": [
{ "name": "no_import_cycles", "severity": "pass", "message": "no import cycles detected" }
],
"failed": [
{ "name": "signal_floor", "severity": "fail", "message": "quality_signal=4200 below floor 5000" }
]
}Scope: Channel.
quality.session_ended
Emitted at the end of a quality scan, regardless of outcome. Tells the UI to re-enable the “Scan now” button.
Payload schema:
{
"branch": "main",
"ok": true,
"error": "",
"repo_too_large": false
}| Field | Type | Description |
|---|---|---|
branch | string | Branch the scan ran on |
ok | bool | true on success; false if the scan errored or the repo was too large |
error | string | Error string when ok=false |
repo_too_large | bool | true when the scan was refused because the file count exceeded quality.max_files |
Scope: Channel.
quality.scan_progress (reserved)
The event type is declared (internal/api/events_hub.go) and the panel handles it, but the daemon does not emit it yet. Reserved for a future progress-reporting pass; until then the panel falls back to an indeterminate “Scanning…” label.
quality.scan_cancelled (reserved)
The event type is declared but no cancel endpoint exists; the engine cannot be cancelled mid-scan today. Reserved for symmetry with the panel’s planned cancel button.
gate.approval_resolved
A previously-broadcast approval was resolved (either through the UI, a Discord/Slack button click, or timeout). The UI should dismiss the matching approval card.
Also fanned out on container teardown — MultiManagerResolver.Remove calls Manager.Shutdown(), which resolves every still-pending request with decision: "deny" and actor: "shutdown" so the FE card and the electron dock-bouncer clear when the agent container goes away mid-prompt.
The renderer treats event delivery as best-effort: on every WebSocket reconnect it GETs /api/gate/approvals
and locally synthesizes a resolved event (with actor: "rehydrate") for any approval it had on file that’s missing from the snapshot. A missed real gate.approval_resolved therefore self-heals on the next onOpen — see Gates: WS-reconnect rehydration
.
Payload schema:
{
"req_id": "gate-req-8f1c...",
"decision": "once",
"actor": "U01ABCD2EF"
}| Field | Type | Description |
|---|---|---|
req_id | string | Correlation id of the resolved request |
decision | string | "once", "session", or "deny" |
actor | string | User id that made the decision (platform user id on Discord/Slack; local.DefaultAuthorID on the desktop when unset) |
Scope: Channel (the container’s channel).
review.comment
Emitted once per finding the review agent reports during a run, via either the built-in ReportFindings tool (read off the agent’s stream) or the report_review_findings MCP tool. Deduplicated by comment id upstream so each id arrives at most once.
Payload schema:
{
"id": "rev-7f2a",
"path": "internal/api/foo.go",
"line": 42,
"side": "RIGHT",
"body": "This nil check looks redundant — the caller already validated `req`.",
"category": "correctness"
}| Field | Type | Description |
|---|---|---|
id | string | Stable per-comment id assigned at parse time |
path | string | Repo-relative file path |
line | int | Line number on the indicated side of the diff |
side | string | "RIGHT" (added/modified) or "LEFT" (deleted) |
body | string | The agent’s comment text |
category | string | Finding type slug the review gave, e.g. correctness; omitted when none |
verdict | string | The dedup pass’s check against the code: real, false_positive or already_fixed; omitted until a pass has checked it |
verdict_reason | string | One sentence on what in the code decided the verdict; omitted when none |
impact | string | With verdict real: blocking or nice_to_have; omitted otherwise |
transcripts | string[] | Full paths of the Claude transcripts of the agent passes that reported or changed the comment, oldest first; omitted when none |
run_id | int | History id of the review run that reported the comment; omitted when none was recorded |
Scope: Channel (the channel that started the review).
review.comment_removed
Emitted when a comment leaves the review session, either because it was deleted (DELETE /review/comments/{cid}) or because the dedup pass dropped it as a duplicate (POST /review/dedup). The FE removes it from the panel.
Payload schema:
{ "id": "rev-7f2a" }| Field | Type | Description |
|---|---|---|
id | string | Id of the removed comment |
Scope: Channel.
review.comment_updated
Emitted when a comment changes in place: the dedup pass (POST /review/dedup) appends what a kept comment’s dropped duplicates added, trims a comment that bundles several issues to the ones no other comment covers, moves a finding to the line it is about, or records its verdict (and the impact of a real one) on an agent comment, and when an agent pass (a review run, the dedup pass, the verification) ends, for each comment it reported or changed, which now lists its transcript. Same payload as review.comment; the FE swaps in the new comment.
Scope: Channel.
review.status
Emitted on every review session status transition (idle → loading → ready → reviewing → verifying → ready|error; verifying is the verification step
, skipped when it is off), and with idle when the session is deleted, which also stops a run in progress. The FE swaps affordances based on this; no polling needed. The sidebar lights a ready session’s rev pill, and patches the channel’s review_running and last_activity_at from it, so a reviewing channel shows as running, in Recent too.
Payload schema:
{ "status": "reviewing", "error": "" }| Field | Type | Description |
|---|---|---|
status | string | "idle" | "loading" | "ready" | "reviewing" | "verifying" | "error" |
error | string | Populated only when status == "error"; omitted otherwise |
Scope: Global, so every window’s sidebar hears it.
review.approved
Emitted when the PR under review was approved on GitHub from the Review panel (POST /review/approve
). The panel shows the approved chip from it.
Payload schema:
{ "channel_id": "ch-1", "pr": 104, "commit": "abc123..." }| Field | Type | Description |
|---|---|---|
channel_id | string | The channel whose review session it is |
pr | int | The PR number |
commit | string | The head commit the approval covers, stored as the session’s approved_sha |
Scope: Channel.
plugins.changed
A plugin was installed, upgraded or removed, or one of its containers stopped. Clients refetch GET /api/plugins.
Payload schema: none.
Scope: Global.
plugin.run_started
A plugin
started an agent run with POST /api/plugin/v1/runs. channel_id is the project channel whose container asked for it. A plugin pane in that channel subscribes to the hidden thread to follow the run’s agent.status and messages in its agent chat.
Payload schema:
{ "plugin_id": "cogtest", "run_id": "ask-…", "thread_id": "plugin:cogtest-a1b2c3" }| Field | Type | Description |
|---|---|---|
plugin_id | string | The plugin that started the run |
run_id | string | The run’s id |
thread_id | string | The channel’s hidden plugin:<id> thread, where the run goes |
Scope: Channel.
plugin.composer_switch
A plugin’s composer switch
changed in a chat: the owner turned it, or the plugin set or reset it. channel_id is the chat, a channel or one of its threads. The composer’s pills follow it.
Payload schema:
{ "plugin_id": "demo", "switch_id": "mode", "on": true, "status": "ok", "title": "Mode is on" }| Field | Type | Description |
|---|---|---|
plugin_id | string | The switch’s plugin |
switch_id | string | The switch, as its manifest declares it |
on | bool | Whether it’s on |
status | string | busy, ok, error, or left out |
title | string | Its tooltip, left out for the manifest’s |
Scope: Channel.
Broadcast Flow
- Event source calls a typed broadcast method (e.g.,
BroadcastMessageCreated). - The method constructs an
Eventstruct with the appropriate type and data, then callsBroadcast. Broadcastsets thetimestamptotime.Now().UnixMilli()and marshals the event to JSON.- Under
RLock, a snapshot of all current subscribers is taken. - The lock is released.
- For each subscriber in the snapshot:
a. Acquire the subscriber’s
writeMu. b. Check if the subscriber’s channel filter matches the event’schannel_id. c. If matched (or filter is empty), write the JSON as a WebSocket text message. d. ReleasewriteMu. e. If the write fails, log the error and unregister the subscriber.
Broadcast Methods
| Method | Event Type | Data Type | Scope |
|---|---|---|---|
BroadcastMessageCreated | message.created | MessageEventData | Channel |
BroadcastMessageStreaming | message.streaming | MessageStreamingData | Channel |
BroadcastMessagesProcessed | messages.processed | MessagesProcessedData | Channel |
BroadcastMessageDeleted | message.deleted | MessageDeletedData | Channel |
BroadcastMessageUpdated | message.updated | MessageUpdatedData | Channel |
BroadcastAgentStatus | agent.status | AgentStatusEventData | Global |
BroadcastToolUse | tool.use | ToolUseEventData | Channel |
BroadcastAgentThinking | agent.thinking | AgentThinkingEventData | Channel |
BroadcastToolResult | tool.result | ToolResultEventData | Channel |
BroadcastAgentActivity | agent.activity | AgentActivityEventData | Channel |
BroadcastAskUser | agent.ask_user | AskUserQuestionEventData | Channel |
BroadcastAskResolved | agent.ask_resolved | — | Channel |
BroadcastExitPlan | agent.exit_plan | ExitPlanModeEventData | Channel |
BroadcastPlanResolved | agent.plan_resolved | — | Channel |
BroadcastTodoWrite | agent.todos | TodoWriteEventData | Channel |
BroadcastChannelCreated | channel.created | map[string]string{"channel_id": id} | Channel |
BroadcastChannelDeleted | channel.deleted | nil | Channel |
BroadcastChannelUpdated | channel.updated | ChannelUpdatedData | Global |
BroadcastChannelPR | channel.pr | events.PREventData | Global |
BroadcastChannelAgentConfig | channel.agent_config | map[string]string{"model_override", "effort_override"} | Global |
BroadcastChannelLearn | channel.learn | map[string]string{"learn"} | Global |
BroadcastLearnStarted | learn.started | map[string]string{"learn_channel_id"} | Global |
BroadcastLearnProposals | learn.proposals | map[string]any{"proposals", "withdrawn"?} of []*db.LearnProposal | Global |
BroadcastLearnPass | learn.pass | *db.LearnPass | Global |
BroadcastLearnProposalUpdated | learn.proposal_updated | *db.LearnProposal | Global |
BroadcastChannelExplain | channel.explain | map[string]string{"explain"} | Global |
BroadcastExplainUpdated | explain.updated | *db.Explanation | Global |
BroadcastAgentInstanceRegistered | agent_instance.registered | AgentInstanceEventData | Channel |
BroadcastAgentInstanceUnregistered | agent_instance.unregistered | AgentInstanceEventData | Channel |
BroadcastAgentInstanceMetadata | agent_instance.metadata | AgentInstanceEventData | Channel |
BroadcastContainerRegistered | container.registered | ContainerEventData | Global |
BroadcastContainerStatusChanged | container.status_changed | ContainerEventData | Global |
BroadcastContainerRemoved | container.removed | ContainerEventData | Global |
BroadcastImageBuildStatus | image.build_status | ImageBuildStatusData | Global |
BroadcastImageUpdateAvailable | image.update_available | ImageUpdateAvailableData | Global |
BroadcastTaskCreated | task.created | TaskEventData | Global |
BroadcastTaskUpdated | task.updated | TaskEventData | Global |
BroadcastTaskDeleted | task.deleted | TaskEventData | Global |
BroadcastTaskRunCompleted | task.run_completed | TaskRunEventData | Global |
BroadcastTicketEvent | ticket.created / ticket.updated / ticket.deleted | map[string]any | Global |
BroadcastWorkflowRunStarted | workflow.run_started | WorkflowRunEventData | Global |
BroadcastWorkflowRunCompleted | workflow.run_completed | WorkflowRunEventData | Global |
BroadcastWorkflowRunPaused | workflow.run_paused | WorkflowRunEventData | Global |
BroadcastWorkflowNodeStarted | workflow.node_started | WorkflowNodeEventData | Global |
BroadcastWorkflowNodeCompleted | workflow.node_completed | WorkflowNodeEventData | Global |
BroadcastGateApprovalRequested | gate.approval_requested | GateApprovalEventData | Channel |
BroadcastGateApprovalResolved | gate.approval_resolved | GateApprovalResolvedData | Channel |
BroadcastQualityEvent | quality.session_started / quality.scanned / quality.rules_violated / quality.session_ended | any | Channel |
Client Reconnection
The events system does not maintain client state between connections. When a client reconnects:
- It must re-subscribe to channels (via query parameter or subscribe message).
- There is no event replay or missed-event recovery.
- The client should refresh its local state (e.g., re-fetch messages) after reconnecting.
Broadcaster Interface
External components (orchestrator, agent, bot) interact with the events system through the events.Broadcaster interface:
type Broadcaster interface {
BroadcastMessageCreated(channelID string, data MessageEventData)
BroadcastMessageStreaming(channelID string, data MessageStreamingData)
BroadcastAgentStatus(channelID string, data AgentStatusEventData)
BroadcastToolUse(channelID string, data ToolUseEventData)
BroadcastAgentThinking(channelID string, data AgentThinkingEventData)
BroadcastToolResult(channelID string, data ToolResultEventData)
BroadcastAgentActivity(channelID string, data AgentActivityEventData)
BroadcastAskUser(channelID string, data AskUserQuestionEventData)
BroadcastExitPlan(channelID string, data ExitPlanModeEventData)
BroadcastTodoWrite(channelID string, data TodoWriteEventData)
BroadcastMessagesProcessed(channelID string, data MessagesProcessedData)
BroadcastChannelCreated(parentChannelID, channelID string)
BroadcastChannelDeleted(channelID string)
BroadcastAgentInstanceRegistered(channelID string, data AgentInstanceEventData)
BroadcastAgentInstanceUnregistered(channelID string, data AgentInstanceEventData)
BroadcastAgentInstanceMetadata(channelID string, data AgentInstanceEventData)
BroadcastImageBuildStatus(data ImageBuildStatusData)
BroadcastImageUpdateAvailable(data ImageUpdateAvailableData)
BroadcastWorkflowRunStarted(data WorkflowRunEventData)
BroadcastWorkflowRunCompleted(data WorkflowRunEventData)
BroadcastWorkflowRunPaused(data WorkflowRunEventData)
BroadcastWorkflowNodeStarted(data WorkflowNodeEventData)
BroadcastWorkflowNodeCompleted(data WorkflowNodeEventData)
BroadcastGateApprovalRequested(channelID string, data GateApprovalEventData)
BroadcastGateApprovalResolved(channelID string, data GateApprovalResolvedData)
BroadcastLearnStarted(channelID, learnChannelID string)
BroadcastExplainUpdated(e *db.Explanation)
BroadcastLearnPass(p *db.LearnPass)
}The EventsHub also implements ContainerBroadcaster for container lifecycle events:
type ContainerBroadcaster interface {
BroadcastContainerRegistered(data ContainerEventData)
BroadcastContainerRemoved(data ContainerEventData)
BroadcastContainerStatusChanged(data ContainerEventData)
}Container events are broadcast globally (empty channel ID) so all connected clients receive them regardless of their channel subscription.