Events and threads

An event in ac7 is a message from one member to another, or a state change one member needs to know about. It has a sender, a target, a body, a timestamp, a thread, and a bag of meta fields. That’s it — no priority queue, no routing key hierarchy, no topic globbing.

The system is push, not poll. Events arrive at agents in their MCP transport within milliseconds of the originating operation. There’s no inbox to drain.

Message shape

interface Message {
  id: string;
  ts: number;                      // epoch ms
  to: string | null;               // member name or null for broadcast
  from: string | null;             // server-stamped from the authenticated caller
  title: string | null;
  body: string;
  level: LogLevel;                 // debug|info|notice|warning|error|critical
  data: Record<string, unknown>;   // arbitrary metadata; reserved keys are stripped
  attachments: Attachment[];       // virtual filesystem refs (always an array)
}

from is always stamped by the broker based on who authenticated the request. Push payloads can’t override it; it’s trusted. to is null for broadcasts, a member name for DMs.

data carries arbitrary metadata. The broker reserves a few keys and strips them from incoming data.* fields before delivery so a malicious push can’t spoof from / thread / ts / msg_id. Everything else flows through verbatim.

attachments is always an array (empty when there are no files). Render image/* types inline; everything else as a download chip.

The lifecycle

1. Push          ── operator runs `ac7 push`, agent calls a tool, or
                    web UI clicks send. Hits POST /push.

2. Broker        ── validates against the schema, writes to the
                    event log, computes recipients (see "thread
                    routing" below).

3. Fanout        ── for each recipient, push the message frame to
                    every live SSE subscriber on `/subscribe?name=X`.

4. SSE forwarder ── runner's forwarder loop receives the SSE frame.
                    Suppresses self-echoes for chat-shaped messages.

5. Notification  ── runner wraps the event as an `mcp_notification`
                    IPC frame and sends it to the bridge.

6. MCP delivery  ── claude-code: bridge emits a real
                    `notifications/claude/channel` MCP notification
                    on stdio.
                    codex: channel sink dispatches as `turn/start`
                    (idle) or `turn/steer` (active) JSON-RPC.

7. Agent reacts  ── claude wraps the content in a `<channel>` tag
                    and treats it as ambient input.
                    codex receives it as a UserInput item in its
                    next model call.

No polling. No inbox to drain.

The four thread types

Messages route by the data.thread key. Four legal values:

data.threadRecipients
(omitted) or chan:generalThe implicit general channel — every team member
chan:<channelId>Members of the named channel
obj:<objectiveId>The objective’s thread members (originator + assignee + watchers + members with members.manage)
(omitted) with to: <name> setA DM — only the named member

The fanout logic lives in packages/core/src/event-log.ts:

function matchesViewer(message: Message, viewerName: string, ...): boolean {
  // DMs: match if you're the sender or the recipient
  if (message.to !== null) return message.from === viewerName || message.to === viewerName;

  // Channel-tagged: match if you're a member of that channel
  if (typeof message.data.thread === 'string') {
    return matchesChannel(message.data.thread, viewerName, ...);
  }

  // Untagged broadcasts go to general — everyone sees them
  return true;
}

general is special-cased as implicit — every member is a member, nobody appears in a channel_members row for it. chan:<id> and obj:<id> route through their respective member lists.

The <channel> framing on the agent side

Both runners present incoming events to the agent inside an unmistakable framing wrapper so the model treats it as ambient signal, not fresh user input.

For claude-code (translated through the notifications/claude/channel capability):

<channel source="ac7" thread="chan:abc-123" from="alice"
         level="info" msg_id="msg-..." ts="04/15/26 14:23:45 UTC">
  pull latest main and run smoke tests
</channel>

For codex (rendered as text inside a turn/start / turn/steer UserInput):

<channel kind="chat" from="alice" thread="general" ts="...">
  pull latest main and run smoke tests
</channel>

The attribute set is mostly the same; the formatting differs by runner. Reserved meta fields:

  • from — sender’s name
  • threadgeneral / chan:<id> / obj:<id> / dm
  • level — log level
  • msg_id — unique id (for dedup; agents rarely use it)
  • ts — fixed-width human datetime, MM/DD/YY HH:MM:SS UTC

Arbitrary data.* keys from the push appear as additional attributes. The agent’s briefing tells it to treat <channel> content as new input and react immediately, the same way it’d react to a user prompt.

Objective lifecycle as channel events

Objective state transitions arrive on the assignee’s stream as channel-shaped events with data.thread = 'obj:<id>'. Each carries enough information for the agent to react without a follow-up objectives_view (though the agent should call objectives_view before doing anything substantive, since the description in data is just a hint).

Lifecycle event kinds:

EventPayload (in data.event)
assigned{ objectiveId, title, outcome, originator, watchers }
blocked{ objectiveId, blockReason }
unblocked{ objectiveId }
completed{ objectiveId, result }
cancelled{ objectiveId, reason }
reassigned{ objectiveId, fromAssignee, toAssignee, note }
watcher_added{ objectiveId, name }
watcher_removed{ objectiveId, name }

Watchers and the originator receive the same events on their own streams — the broker fans out to every thread member.

Tool-list refresh

Whenever an objective event lands for an assignee — specifically one that changes whether the agent has the objective open — the runner’s objectives tracker:

  1. Re-fetches the open set from the broker (debounced 150ms to coalesce bursts).
  2. Emits an MCP notifications/tools/list_changed notification 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 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 MCP session metadata, not in chat history, so they survive compaction without needing to be re-injected into the conversation.

For codex there’s no exact analog (codex’s MCP client doesn’t expose a list-changed event the way claude’s does), but the runner re-runs defineTools on every tools/list call so the descriptions are always fresh on the next call.

Why broker → agent uses notifications, not tool calls

A tool call requires the agent to decide to call the tool. A <channel> notification arrives whether the agent was going to check its mailbox or not. That’s the point: push, not poll.

Tool calls are the right primitive for agent → broker pushes (every send / broadcast / objectives_complete IS a tool call). The asymmetry is intentional — broker → agent is push, agent → broker is pull.

Self-echo suppression

When a member sends a DM or broadcast, the broker fanout delivers the event back to the sender’s own SSE stream too — so a member with multi-device sessions stays in sync. The runner’s forwarder filters these self-echoes on the chat path; otherwise the agent would see its own outbound messages reappear and loop itself.

Self-echoes are NOT suppressed for objective events: the agent DOES want to know when it successfully completed an objective it was working on. The asymmetry lives in shouldSuppressSelfEcho(message) — chat-shaped messages from self get suppressed; lifecycle events do not.

Activity events vs message events

Two parallel streams travel through the system:

MessagesActivity events
Producermembers (operators, agents, web UI)runner’s trace host
Storagebroker messages tablebroker activity table
SubscribeSSE /subscribe?name=XSSE /members/:name/activity/stream
Persistenceevent log, queryable via /historyappend-only, time-range queryable
Visibilityrecipients (per thread routing)self + members with activity.read

Activity events are objective_open / objective_close markers, llm_exchange typed Anthropic API records, and opaque_http records for everything else. They never reach an agent’s <channel> stream — they’re for director review of “what did this agent actually do.” See activity & traces.

Source of truth

  • packages/sdk/src/types.tsMessage, PushPayload, LogLevel
  • packages/sdk/src/schemas.tsMessageSchema, PushPayloadSchema
  • packages/core/src/event-log.ts — fanout logic, data.thread routing
  • packages/core/src/registry.ts — live SSE subscriber registry
  • packages/cli/src/runtime/forwarder.ts — runner-side SSE forwarder + self-echo suppression
  • packages/cli/src/runtime/agents/codex/channel-sink.ts — codex’s notification → turn/start/steer mapping