Skip to content
Real-Time Events System

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})           ──> skip

Thread 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 Unregister may remove a subscriber that Broadcast is about to write to. This is safe because the connection’s writeMu still guards the write.
  • A failed write triggers Unregister for that client, which is idempotent (map delete).

WebSocket Connection

Endpoint

GET /api/ws

Query Parameters:

ParamTypeDescription
channelsstringComma-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:

  1. Query parameter: Pass ?channels=id1,id2 on the WebSocket URL.
  2. Subscribe message: Send a JSON message after connecting.

Subscribe Message (Client to Server)

{
  "type": "subscribe",
  "channels": ["chan_a", "chan_b", "chan_c"]
}
FieldTypeDescription
typestringMust be "subscribe"
channelsstring[]Channel IDs to subscribe to; replaces the previous subscription

Behavior:

  • Sending a subscribe message replaces the current channel filter entirely (it does not merge).
  • An empty channels array subscribes to all events (no filtering).
  • The subscription update acquires the connection’s writeMu to avoid races with concurrent broadcasts.

Channel Filter Semantics

channels stateBehavior
Empty map (or nil)Receives events for all channels
Non-empty mapReceives 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
}
FieldTypeDescription
typestringEvent type identifier
channel_idstringChannel the event belongs to
dataobjectType-specific payload
timestampint64Unix 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
}
FieldTypeDescription
msg_idstringPlatform-specific message ID
author_idstringAuthor’s platform user ID
author_namestringAuthor’s display name
contentstringFull message content
is_botboolWhether the message was sent by the bot
is_processedboolWhether 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.
priorityintQueue 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_idstringFor 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_beforeint64Unix-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.
forkableboolSet 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..."
}
FieldTypeDescription
contentstringAccumulated 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"
}
FieldTypeDescription
msg_idstringPlatform-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"
}
FieldTypeDescription
msg_idstringMessage ID of the edited message
contentstringThe 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"
}
FieldTypeDescription
statusstring"running", "completed", or "error"
run_idstringUnique 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).
errorstringError message (only when status is "error")
duration_msintTotal run duration in milliseconds (on completion)
num_turnsintNumber of conversation turns (on completion)
stop_reasonstringWhy the agent stopped (e.g., "end_turn", "max_turns")
modelstringModel used for the run
trigger_contentstringContent of the message that triggered the run (on "running" status)
msg_idstringmsg_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.
triggerstringWhat 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.
stoppedboolSet 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_idstringThread 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"
}
FieldTypeDescription
tool_use_idstringPer-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_namestringName of the tool being called
inputstringTool 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..."
}
FieldTypeDescription
textstringThe 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
}
FieldTypeDescription
tool_use_idstringPairs this event with the originating tool.use event (and with the persisted timeline rows).
outputstringTool output, already truncated at 8 KiB by the runner.
is_errorbooltrue 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": ""
}
FieldTypeDescription
activitystringActivity type: "model", "subagent_started", "subagent_progress", "background_tasks" and others
modelstringModel name (when activity is "model")
descriptionstringHuman-readable description of the activity
tasksarrayThe 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 fieldTypeDescription
idstringClaude’s task id, which stops it in a live session
typestringClaude’s task type: local_bash (a shell command), monitor (Claude’s Monitor tool), local_agent, remote_agent, local_workflow and others
descriptionstringWhat the task does
started_atstringWhen 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
    }
  ]
}
FieldTypeDescription
questionsarrayList of questions to present (1–4 per call)
questions[].questionstringThe question text
questions[].headerstringShort header/label for the question, rendered as a chip
questions[].optionsarraySelectable options (label, description, optional preview)
questions[].options[].previewstringOptional mockup / code snippet shown in a panel when the option is hovered or focused
questions[].multiSelectboolWhether 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"
}
FieldTypeDescription
planstringThe full plan content (markdown)
planFilePathstringPath 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"
}
FieldTypeDescription
channel_idstringID 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
}
FieldTypeDescription
channel_idstringChannel whose state changed
branchstringCurrent branch (git rev-parse --abbrev-ref HEAD)
commitstringShort commit hash (git rev-parse --short HEAD)
diff_additionsintLines added in the working-tree diff
diff_deletionsintLines removed in the working-tree diff
subjectstringThe commit’s subject line
upstreamstringTracking branch, e.g. origin/main (omitted when there’s none)
ahead / behindintCommits the branch is ahead of / behind upstream
sync_basestringA worktree thread’s base branch, when it still resolves
base_ahead / base_behindintCommits the checkout is ahead of / behind sync_base
namestringNew display name (only on rename; omitted otherwise)
descriptionstringNew description, empty when cleared (only on a description change; omitted otherwise)
ticket_urlstringNew 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"
}
FieldTypeDescription
channel_idstringThe thread
kindstringpr (GitHub) or mr (GitLab)
numberintThe PR number or MR IID
urlstringThe PR/MR’s web URL
head_shastringThe head commit the checks ran on
base_refstringThe branch the PR/MR merges into, omitted when unknown
checksobjecttotal, passed, failed and pending checks; a cancelled or skipped one counts only toward total
failedarrayThe failed checks: id, name, group (the workflow or stage), url, run_id, and infra for a GitLab runner failure
merge_statestringclean, blocked, behind, conflict, draft or unknown
auto_mergeboolThe forge’s auto-merge is on
merged, closedboolThe PR/MR was merged or closed
merge_armedstringThe method “merge when ready” was armed with; omitted when not armed
merge_methodsarrayThe methods the repo allows; omitted once merged or closed
merge_methodstringThe method a merge would use now
pr_attentionstringfailed (lights the ci pill), fixing (ci…), conflict (cnf) or empty
auto_fixboolThe thread’s effective auto-fix setting
auto_fix_defaultboolThe config’s pr.auto_fix
attemptsintFixes sent for the current failure streak
max_attemptsintThe config’s pr.max_attempts
reasonstringWhy 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_shastringThe commit the thread’s checkout is at; omitted when it can’t be read
unresolved_threadsintThe PR/MR’s unresolved review threads; omitted when none (see pr.md )
alertstringA 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"
}
FieldTypeDescription
model_overridestringThe channel’s model, or empty when it inherits the config’s
effort_overridestringThe 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" }
FieldTypeDescription
learnstring"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" }
FieldTypeDescription
learn_channel_idstringThe 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", "...": "..." } ]
}
FieldTypeDescription
proposalsarrayThe 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
withdrawnarrayThe 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"
}
FieldTypeDescription
statusstringqueued, 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" }
FieldTypeDescription
explainstring"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" }]
}
FieldTypeDescription
runningboolWhether a live session is up for the channel
started_atstringWhen the session started (while running)
session_idstringThe Claude session it runs (while running)
busyboolWhether a turn is in progress
backgroundstringThe background tasks (shells, subagents, monitors) it still waits on, empty when there are none
background_tasksarrayThose tasks, shaped as an agent.activity ’s tasks; left out when there are none
reasonstringWhy 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" }
FieldTypeDescription
statusstringexited 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"
}
FieldTypeDescription
container_idstringDocker container ID
channel_idstringChannel the container belongs to
typestringContainer type: "agent", "shell", or "chrome"
statusstringLifecycle status (always "running" on registration)
container_namestringDocker 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"
}
FieldTypeDescription
container_idstringDocker container ID
channel_idstringChannel the container belongs to
typestringContainer type
statusstringNew status: "running", "stopped", or "pending-removal"
container_namestringDocker container name
remove_atstring?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"
}
FieldTypeDescription
container_idstringDocker container ID
channel_idstringChannel the container belonged to
typestringContainer type
statusstringStatus at time of removal
container_namestringDocker 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"
}
FieldTypeDescription
ticket_idstringThe 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": ""
}
FieldTypeDescription
run_idstringWorkflow run ID
workflow_namestringName of the workflow definition
channel_idstringChannel context (may be empty)
statusstring"running", "paused", "completed", "failed", or "cancelled"
paused_node_idstringNode ID that caused the pause (only on "paused" status)
errorstringError 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"
}
FieldTypeDescription
run_idstringParent workflow run ID
node_idstringNode identifier within the workflow
statusstring"running", "success", "failed", or "skipped"
inputstringNode’s rendered input — bash script / resolved prompt (truncated to 1000 chars, only on completion)
session_idstringPrompt node’s Claude session id (empty for non-prompt nodes)
outputstringNode output text (truncated to 1000 chars, only on completion)
iterationintegerLoop 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"
  }
}
FieldTypeDescription
req_idstringGate-server-assigned correlation id; echoed back on the resolve POST
kindstring"connect", "execve", or "docker-http" — selects how the UI renders the target
targetstringHuman-readable summary (socket path, command line, or METHOD /path)
messagestringMatching rule’s message field, shown as extra context
detailsobject (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
}
FieldTypeDescription
branchstringBranch the scan ran on
okbooltrue on success; false if the scan errored or the repo was too large
errorstringError string when ok=false
repo_too_largebooltrue 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"
}
FieldTypeDescription
req_idstringCorrelation id of the resolved request
decisionstring"once", "session", or "deny"
actorstringUser 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"
}
FieldTypeDescription
idstringStable per-comment id assigned at parse time
pathstringRepo-relative file path
lineintLine number on the indicated side of the diff
sidestring"RIGHT" (added/modified) or "LEFT" (deleted)
bodystringThe agent’s comment text
categorystringFinding type slug the review gave, e.g. correctness; omitted when none
verdictstringThe dedup pass’s check against the code: real, false_positive or already_fixed; omitted until a pass has checked it
verdict_reasonstringOne sentence on what in the code decided the verdict; omitted when none
impactstringWith verdict real: blocking or nice_to_have; omitted otherwise
transcriptsstring[]Full paths of the Claude transcripts of the agent passes that reported or changed the comment, oldest first; omitted when none
run_idintHistory 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" }
FieldTypeDescription
idstringId 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": "" }
FieldTypeDescription
statusstring"idle" | "loading" | "ready" | "reviewing" | "verifying" | "error"
errorstringPopulated 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..." }
FieldTypeDescription
channel_idstringThe channel whose review session it is
printThe PR number
commitstringThe 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" }
FieldTypeDescription
plugin_idstringThe plugin that started the run
run_idstringThe run’s id
thread_idstringThe 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" }
FieldTypeDescription
plugin_idstringThe switch’s plugin
switch_idstringThe switch, as its manifest declares it
onboolWhether it’s on
statusstringbusy, ok, error, or left out
titlestringIts tooltip, left out for the manifest’s

Scope: Channel.


Broadcast Flow

  1. Event source calls a typed broadcast method (e.g., BroadcastMessageCreated).
  2. The method constructs an Event struct with the appropriate type and data, then calls Broadcast.
  3. Broadcast sets the timestamp to time.Now().UnixMilli() and marshals the event to JSON.
  4. Under RLock, a snapshot of all current subscribers is taken.
  5. The lock is released.
  6. For each subscriber in the snapshot: a. Acquire the subscriber’s writeMu. b. Check if the subscriber’s channel filter matches the event’s channel_id. c. If matched (or filter is empty), write the JSON as a WebSocket text message. d. Release writeMu. e. If the write fails, log the error and unregister the subscriber.

Broadcast Methods

MethodEvent TypeData TypeScope
BroadcastMessageCreatedmessage.createdMessageEventDataChannel
BroadcastMessageStreamingmessage.streamingMessageStreamingDataChannel
BroadcastMessagesProcessedmessages.processedMessagesProcessedDataChannel
BroadcastMessageDeletedmessage.deletedMessageDeletedDataChannel
BroadcastMessageUpdatedmessage.updatedMessageUpdatedDataChannel
BroadcastAgentStatusagent.statusAgentStatusEventDataGlobal
BroadcastToolUsetool.useToolUseEventDataChannel
BroadcastAgentThinkingagent.thinkingAgentThinkingEventDataChannel
BroadcastToolResulttool.resultToolResultEventDataChannel
BroadcastAgentActivityagent.activityAgentActivityEventDataChannel
BroadcastAskUseragent.ask_userAskUserQuestionEventDataChannel
BroadcastAskResolvedagent.ask_resolved—Channel
BroadcastExitPlanagent.exit_planExitPlanModeEventDataChannel
BroadcastPlanResolvedagent.plan_resolved—Channel
BroadcastTodoWriteagent.todosTodoWriteEventDataChannel
BroadcastChannelCreatedchannel.createdmap[string]string{"channel_id": id}Channel
BroadcastChannelDeletedchannel.deletednilChannel
BroadcastChannelUpdatedchannel.updatedChannelUpdatedDataGlobal
BroadcastChannelPRchannel.prevents.PREventDataGlobal
BroadcastChannelAgentConfigchannel.agent_configmap[string]string{"model_override", "effort_override"}Global
BroadcastChannelLearnchannel.learnmap[string]string{"learn"}Global
BroadcastLearnStartedlearn.startedmap[string]string{"learn_channel_id"}Global
BroadcastLearnProposalslearn.proposalsmap[string]any{"proposals", "withdrawn"?} of []*db.LearnProposalGlobal
BroadcastLearnPasslearn.pass*db.LearnPassGlobal
BroadcastLearnProposalUpdatedlearn.proposal_updated*db.LearnProposalGlobal
BroadcastChannelExplainchannel.explainmap[string]string{"explain"}Global
BroadcastExplainUpdatedexplain.updated*db.ExplanationGlobal
BroadcastAgentInstanceRegisteredagent_instance.registeredAgentInstanceEventDataChannel
BroadcastAgentInstanceUnregisteredagent_instance.unregisteredAgentInstanceEventDataChannel
BroadcastAgentInstanceMetadataagent_instance.metadataAgentInstanceEventDataChannel
BroadcastContainerRegisteredcontainer.registeredContainerEventDataGlobal
BroadcastContainerStatusChangedcontainer.status_changedContainerEventDataGlobal
BroadcastContainerRemovedcontainer.removedContainerEventDataGlobal
BroadcastImageBuildStatusimage.build_statusImageBuildStatusDataGlobal
BroadcastImageUpdateAvailableimage.update_availableImageUpdateAvailableDataGlobal
BroadcastTaskCreatedtask.createdTaskEventDataGlobal
BroadcastTaskUpdatedtask.updatedTaskEventDataGlobal
BroadcastTaskDeletedtask.deletedTaskEventDataGlobal
BroadcastTaskRunCompletedtask.run_completedTaskRunEventDataGlobal
BroadcastTicketEventticket.created / ticket.updated / ticket.deletedmap[string]anyGlobal
BroadcastWorkflowRunStartedworkflow.run_startedWorkflowRunEventDataGlobal
BroadcastWorkflowRunCompletedworkflow.run_completedWorkflowRunEventDataGlobal
BroadcastWorkflowRunPausedworkflow.run_pausedWorkflowRunEventDataGlobal
BroadcastWorkflowNodeStartedworkflow.node_startedWorkflowNodeEventDataGlobal
BroadcastWorkflowNodeCompletedworkflow.node_completedWorkflowNodeEventDataGlobal
BroadcastGateApprovalRequestedgate.approval_requestedGateApprovalEventDataChannel
BroadcastGateApprovalResolvedgate.approval_resolvedGateApprovalResolvedDataChannel
BroadcastQualityEventquality.session_started / quality.scanned / quality.rules_violated / quality.session_endedanyChannel

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.