Events and channels
An event in control17 is a message from an operator (or another agent) to a slot, or an objective state change that slot needs to know about. It has a source, a target, a body, a timestamp, a thread, and a bag of meta fields. That’s it — no priority fields, no routing keys, no topic hierarchy.
The lifecycle
- Push — an operator runs
c17 push --agent ALPHA-1 --body "…", or posts to/push, or clicks send in the web UI. The broker authenticates the push, resolvesALPHA-1to a slot, and writes the event to its log. - Fanout — the broker pushes the event to every SSE subscriber
for that slot. Usually there’s exactly one: the operator’s
c17 claude-coderunner. - IPC relay — the runner receives the SSE frame in its
forwarder loop, suppresses self-echoes, wraps it as an
mcp_notificationIPC frame, and sends it across the Unix socket to the MCP bridge. - Stdio delivery — the bridge emits the frame as a real MCP
notifications/claude/channelnotification on its stdio transport. Claude Code recognizes theclaude/channelcapability and wraps the content in a<channel>tag on its next turn.
No polling. No queue the agent has to drain. The event is present in the conversation within milliseconds of the push.
The channel tag
The bridge’s MCP notification translates on the agent side into:
<channel source="cmdcntr" thread="primary" from="ACTUAL" level="info" msg_id="msg-..." ts="04/15/26 14:23:45 UTC" ts_ms="1700000000000">
pull latest main and run the smoke tests
</channel>
Claude Code is instructed (via the runner’s composed briefing) to
treat <channel> tags as new operator input and react to them
immediately — the same way it’d react to a user prompt. Every tag
includes:
from— callsign of the senderthread— which thread this message belongs to (see below)level—info | warning | criticalmsg_id— unique id for dedupts— fixed-width human datetime,MM/DD/YY HH:MM:SS UTCts_ms— unix milliseconds as a string- plus any custom
datakeys the sender attached
Reserved meta keys are stripped from incoming data.* fields
before delivery, so a malicious push can’t spoof from or thread.
Thread types
There are three thread types in v1:
| Thread | When it’s used |
|---|---|
primary | The squadron’s open team channel. Broadcasts land here. |
dm | A direct message between two callsigns. Addressed by agentId. |
obj:<id> | A discussion thread scoped to a specific objective. Members: originator + assignee + commanders + explicit watchers. |
The obj:<id> thread is how lifecycle events, status updates, and
discussion posts for an objective stay bundled together. Non-members
never see these threads (the server scopes fanout); members see
every lifecycle transition (assigned, blocked, unblocked,
completed, cancelled, reassigned) plus every discussion message.
Objective events trigger tool-description refresh
When an objective event lands for a slot — specifically one that changes whether the slot has an open objective — the runner’s objectives tracker does two things:
- Refetches the open set from the server (debounced 150ms to coalesce bursts)
- Emits an MCP
notifications/tools/list_changednotification out to the bridge
The agent’s MCP client treats tools/list_changed as a prompt to
re-fetch tool descriptions. When it does, the runner regenerates
them from the fresh open-objectives list, so objectives_list
(for example) carries a current summary of the slot’s plate in its
description field.
This is the mechanism that keeps “what am I working on right now” sticky across context compaction: tool descriptions live in session metadata, not message history, so they survive compaction without needing to be re-injected by the agent.
Why tags instead of tool calls
A tool call requires the agent to decide to call the tool. A
<channel> tag arrives whether the agent was going to check its
mailbox or not. That’s the point: push, not poll.
Tool calls are still the right primitive for agent → broker pushes (an agent pushing to another agent’s slot is just a tool call on the bridge-exposed tools). The asymmetry is intentional — broker → agent is push, agent → broker is pull.
Self-echo suppression
When a slot sends a DM or broadcasts a message, the broker
fanout delivers the event back to the sender’s own SSE stream (so
multi-device sessions stay in sync). The runner’s forwarder filters
these self-echoes on the channel stream — the agent would otherwise
see its own outbound messages reappear as new operator input and
loop itself.
Self-echoes are NOT suppressed for objective events, because the
agent DOES want to know when it successfully completed an objective
it was working on (for example). The asymmetry lives in the
forwarder: shouldSuppressSelfEcho(message) returns true only for
chat-shaped messages from the same slot.