Agency Bus
Status — partially delivered
This page describes both shipped behaviour and target architecture. Only the
sections listed under Delivered today exist in code today. Every
other section is design intent: do not assume it is implemented, and do not
write product code against it without checking agency-ui/src/bus/.
The legacy transports documented in Connections remain the live contract for everything not yet migrated.
Agency UI is adopting a namespaced event bus as its internal realtime backbone. It is similar to a vehicle CAN bus: transports put frames onto a shared logical bus, and independent consumers listen only to the addresses they understand. A window, Agent Face, store or Display does not need to know which WebSocket or HTTP response produced an event.
The analogy stops at the logical topology. Agency Bus is an application-level, typed pub/sub protocol; it is not a CAN implementation and does not inherit CAN’s arbitration, frame size or delivery guarantees.
Goals
one frontend entry point for WebSocket, local UI and Display events;
stable dotted topics across Agent, Mission, Task, Display and User features;
explicit separation of product state, conversation, attention and telemetry;
multiple subscribers without direct component-to-component wiring;
REST snapshots plus ordered, duplicate-tolerant realtime deltas;
the same contracts in Desktop windows, compact companions and kiosks;
incremental adoption without changing existing backend behaviour at once.
Agency Bus is not the database, a command queue or a global Pinia replacement. Django remains authoritative. Stores hold durable client projections; the bus distributes facts and intentions during the current browser session.
Vocabulary
- Resource namespace
Stable catalog identity describing what a resource is, for example
agent.system.veronicaormission.example.first_hello. It is persisted and human meaningful.- Topic
Runtime address describing what happened to a concrete subject, for example
agent.<uuid>.presence.changed. Topics are routing keys, not catalog ids.- Scope
Authorization and visibility boundary such as user, workspace, tenant or DisplayScope. A topic never grants access by itself.
- Property address
A named state value inside a scope, for example
face.expression. Property keys are data addresses; events report changes to them.- Transport
WebSocket, iframe
postMessage, mock backend or local UI adapter that publishes or consumes bus envelopes.
Topic grammar
Canonical topics are lowercase dotted segments:
<domain>.<subject-id>.<concern>.<event>
Examples:
agent.91f2.presence.changed
agent.91f2.face.changed
agent.91f2.attention.requested
mission.7ac4.status.changed
task.f421.progress.changed
display.812a.assignment.changed
display.scope.553b.properties.changed
user.42.notification.created
telemetry.run.a19c.tool.finished
The first segment identifies the domain. The concrete id prevents catalog
namespaces from becoming instance addresses. Wildcard subscriptions may match
one segment (*) or a suffix (**), for example
agent.*.face.changed and telemetry.**. Wildcards are client subscription
syntax and are never authorization rules.
Do not encode commands, JSON values, UI component names or permissions in a topic. Avoid dynamic high-cardinality segments beyond domain identifiers.
UI nodes reuse this grammar as their entity_id. A schema node addressed
agency.mission.{uuid}.execute is subscribed and published under that
topic — one namespace, no Vue↔channel table. See UI Schema (Address
space). The envelope stays snake_case on both sides.
Envelope
All events are normalized at the UI boundary:
{
"v": 1,
"id": "event-uuid",
"topic": "agent.91f2.face.changed",
"kind": "state",
"subject": {
"type": "agent",
"id": "91f2",
"namespace": "agent.system.veronica"
},
"scope": {
"user_id": "42",
"workspace_id": "7"
},
"revision": 18,
"occurred_at": "2026-08-24T12:00:00Z",
"payload": {
"expression": "focused",
"intensity": 0.7
}
}
Required fields are v, id, topic, kind, occurred_at and
payload. subject, scope, revision, correlation_id,
causation_id and trace_id are optional but standardized. Runtime
validation rejects malformed frames before publication.
Field names are snake_case on the wire and in the TypeScript types.
Agency does not translate envelope field names at the transport boundary: one
spelling exists per field across Django, JSON and agency-ui, so a topic or
field rename cannot silently diverge between the two sides.
Kinds separate concerns independently of topic names:
stateFunctional state or a delta used to update a projection.
messageUser-facing conversation content.
attentionA question, approval or notification that needs user awareness.
telemetryBest-effort diagnostics. Losing it must not break product behaviour.
commandA client intention sent towards an authorized backend handler. Commands are never treated as facts and are not applied optimistically unless specified.
ackanderrorCorrelated outcomes for commands or mutations.
Frontend topology
/channel/, /channel/task/, /channel/display/
│
▼
transport adapters
legacy frame → canonical envelope
│
▼
AgencyEventBus
validate · match · publish
│ │ │
▼ ▼ ▼
stores windows diagnostics
│ │
▼ ▼
reactive Vue UI telemetry view
One bus instance exists per application runtime. A transport owns connection, authentication and reconnection. It normalizes inbound frames, but contains no feature-specific UI behaviour. Domain projectors subscribe to topics and update Pinia stores. Vue components consume stores for durable state and may subscribe directly only for ephemeral effects such as an animation.
Subscriptions always return an unsubscribe function. Vue helpers dispose them on unmount. One faulty subscriber cannot prevent delivery to others; errors go to diagnostics without recursively entering the main bus. Publication uses a snapshot of matching subscribers so subscription changes during a callback do not alter the current dispatch.
Ordering and recovery
WebSockets are best effort. Agency uses these rules:
REST snapshot first, then realtime deltas;
event
idsupports duplicate suppression within a bounded cache;revisions are monotonic within a documented stream, never globally;
projectors ignore an equal or older revision;
a revision gap marks the projection stale and schedules REST rehydration;
reconnect rehydrates relevant projections before normal delta processing;
events without revisions are ephemeral facts and cannot repair state;
handlers must be safe when an event is delivered more than once.
Backpressure is bounded. Telemetry may be sampled or dropped first. State and attention events trigger rehydration rather than growing an unlimited queue.
Commands and trust boundaries
Frontend publication does not authorize an operation. A UI action creates a typed command, a transport sends it, and Django validates user, tenant, workspace, subject, scope, expected revision and payload before mutation.
HTML Displays remain sandboxed. Their SDK exposes allowlisted property reads,
subscriptions and commands through postMessage. A Display adapter translates
those messages to Agency Bus envelopes. Arbitrary iframe topics, JavaScript or
scope claims are never forwarded directly to the backend.
Desktop and Agent Face
Desktop windows are ordinary subscribers/projectors. Opening, minimizing or
closing a window changes only its UI representation and does not stop an Agent
or Mission. open_window publishes desktop.<address>.visibility.changed
so the secretary can pop Help, Displays, Media, a Display preview, or a View editor
without knowing Vue. VS3 still covers client→server commands (action clicks,
HITL replies); this slice is server→chrome state.
Agent Face consumes semantic state rather than animation instructions:
agent.<id>.presence.changed delivered
agent.<id>.face.changed delivered
agent.<id>.attention.requested design
An expression payload may contain expression, intensity, gaze and
expires_at. The component decides how focused or questioning is
rendered and provides an accessible textual state. The sidebar companion, full
Veronica window and any other representation read the same projection.
Presence is delivered; see Agent presence slice. face.changed is
published by set_face_expression (Veronica and any agent with that tool).
attention.requested has no producer yet.
Telemetry
Telemetry uses the telemetry. topic root and telemetry kind. Functional
catalog deltas, Agent presence, HITL questions and Display property changes are
not telemetry. Diagnostic subscribers may observe all permitted bus events,
but this mirror must not become a second functional delivery path.
Delivered today
Everything in this section exists in agency-ui/src/bus/. Everything outside
it does not.
envelope.tsAgencyEnvelopetypes andparseEnvelope, which validates unknown data at runtime and returnsnullinstead of throwing or casting.topic.tsTopic and pattern grammar,
matchTopicwith*and**, and aTopicIndexthat buckets subscriptions by first segment so publication does not scan every subscriber.bus.tscreateEventBuswithpublish/subscribe, idempotent unsubscribe, registration-order delivery, a subscriber snapshot per dispatch, per-handler error isolation, a bounded duplicate-id cache and a non-recursive diagnostics hook. It depends on neither Vue, Pinia nor browser globals.projectors/catalog.tsCatalog explorer deltas. Django emits the envelope; there is no browser adapter.
projectors/presence.tsandstores/agentPresence.tsAgent presence, delivered on the
user-{id}group.projectors/desktop.tsWindow open/close from
open_window. Topicdesktop.<address>.visibility.changed.projectors/media.tsNow-playing for the Media singleton. Topic
desktop.media.now_playing.changed.drop/Namespaced drag-and-drop. Source and target are dotted addresses in the same grammar as topics (
file.image,photo.<id>,desktop.media,agent.<uuid>). Handlers registersource × targetpatterns; the most specific match wins. Components only declare their address viauseDropTarget. This is local UI routing, not VS3.
On the Django side, agency/bus.py builds envelopes. Presence and desktop
chrome publish to the user-{id} channel group (sync via
agency.ui.push.push_user_frame, async for consumers). Catalog publishes to
the users group, same fan-out as the old observe path.
Publisher.group_send wraps the channel layer in async_to_sync and cannot
be called from inside a running event loop.
Catalog slice
Catalog deltas are functional state, not telemetry. Django emits a canonical
envelope (kind state) on topic <resource>.<id>.catalog.<action>, for
example agent.a1.catalog.updated. Tool ids that contain dots are coerced
into one topic segment (agency.tools.Read_Help →
tool.agency-tools-read_help.catalog.created); the authoritative id stays
on subject and payload. installCatalogProjector subscribes to
*.*.catalog.* and is the single owner of observe.applyDelta.
dispatchInboundMessage publishes envelopes and ignores leftover
monitor_event / catalog_changed frames so they cannot double-apply.
Ordering key: none. Django emits no revision for catalog deltas, so this
stream is last-write-wins per resource id, repaired by REST rehydration on
reconnect. It deliberately does not claim the monotonic-revision guarantees
described under Ordering and recovery; those apply to streams that carry a
revision, such as Display scopes.
Every other inbound frame — ui.*, message, text, other
monitor_event events, secretary.notify, task frames and display.*
(kiosk properties) — still follows its legacy path untouched. Desktop chrome
from open_window is an envelope (see below), not ui.update.
Agent presence slice
Django emits presence as a canonical envelope, so nothing adapts it: the
envelope is the frame, recognised by v: 1 plus topic. Legacy frames
carry neither, so dispatchInboundMessage publishes envelopes straight to the
bus and leaves everything else on its existing path.
Topic agent.<id>.presence.changed, kind state, payload:
{"state": "thinking", "source": "run", "run_id": "912"}
source is run for the Mission runtime and session for a chat
session. Emitted states are idle, thinking, acting,
waiting_input and error. listening, speaking and blocked
remain reserved with no producer: speaking is deliberately a local
rendering effect driven by arriving text, because the backend cannot know when
a mouth should move and putting that on the wire would make a render detail part
of the contract.
Producers are RunMiddleware — before_agent and wrap_model_call
report thinking, wrap_tool_call reports acting, after_agent
reports idle — plus ThreadRunner for waiting_input and error,
and AgentConsumer for chat sessions, which run no Run at all. A state
equal to the last one published for that Agent is dropped, so the stream stays
proportional to what changed rather than to how many steps ran.
Ordering key: none. Presence describes what is happening now, so it carries
no revision, last write wins, and a lost event self-corrects on the next
transition. It is also ephemeral: a reload starts at idle until the next
event. There is no hydration endpoint, and therefore no second source of truth
to disagree with the stream.
installPresenceProjector is the single owner of agentPresence mutation.
The store is keyed by Agent id, so two portraits of one Agent animate together
and two Agents never share a mood. It carries two layers with different owners:
state is semantic and written only by the projector, while speaking is
written only by the surface rendering text. Publishing to user-{id} keeps
one user’s presence out of another’s session; the topic is a routing key and
never an authorization rule.
Known gap: if a worker dies between thinking and idle, the face keeps
thinking until reload. An expires_at in the payload would bound it.
Desktop chrome slice
open_window / close_window publish desktop.<address>.visibility.changed
(kind state) on user-{id}. Payload {"open": true, "address": "displays"}
for shipped chrome, or address: "display.<uuid>" with props.displayId for
a live Display preview (same as Displays → Open in window). A View name opens
display-view.<uuid> (the editor). media is the singleton Media player.
show_media publishes desktop.media.now_playing.changed (kind state)
plus a visibility open of media. Payload kind is image or youtube
with src / photo_id or youtube_id. Last write wins; reload starts
empty. installMediaProjector is the single owner of that slice.
Ordering key: none. Last write wins. Reload restores from Desktop prefs, not
this stream. installDesktopProjector is the single owner of that mutation via
openAddress / windowsStore. User clicks still write the store locally;
they are not bus producers.
This is not VS3 commands (client → Django). It is the first secretary-driven
chrome fact on the bus, so Veronica can open windows while she talks without
going through legacy ui.update.
Drop routing slice
OS files and in-app resources are classified into the same dotted address
space the bus already uses. A drop is a pair (source, target):
file.image— a png/jpeg/webp from the PCphoto.<uuid>— a library photo (Photos list items are draggable)desktop.media/desktop.photos— Media and Photos singletons (icon, window and player share the address)desktop— the wallpaper (notdesktop.photos)agent.<uuid>— an Agent instance (secretary icon, secretary window, or that Agent’s chat). Dropping an image attaches it to compose: preview + caption, then Send. It does not upload to Media.
Shipped handlers (installDropRouting):
file.image×desktop.media— upload to Photos, play on Mediafile.image×desktop.photos— upload, open Photosphoto.*×desktop.media— play an existing photo (no re-upload)photo.*/file.image×desktop— pin a thumbnail on the wallpaper (drag that icon onto an Agent to attach)file.image/photo.*×agent.*— attach to chat compose (no send)
After a handler mutates, it publishes <target>.drop.accepted (kind
state) so other surfaces can observe where something landed. The handler
owns mutation; there is no drop projector. Dual publication for observation
is allowed; dual application is not.
Register another pair with getDropRegistry().register({ source, target,
handle }). Patterns use the same * / ** grammar as bus
subscriptions.
Migration
Adoption is incremental:
introduce envelope types, topic parser and an in-memory bus with tests — done;
place adapters in front of current
dispatchInboundMessagebehaviour — retired for catalog; Django now emits the envelope;migrate one vertical slice — done for the catalog, chosen ahead of Agent Face because Django already emits catalog deltas, so the bus is proven against real production frames rather than a fixture;
migrate Agent Face/presence once a backend emitter exists — done, and the first producer to skip the adapter layer entirely; catalog later followed the same producer pattern;
secretary desktop chrome (
open_window/close_window) — done;migrate conversation, interruption,
secretary.notifyand task projectors;adapt Display runtime and iframe bridge without weakening its sandbox;
remove legacy conditionals only after contract and regression tests pass;
version backend wire frames when producers can emit canonical envelopes.
During migration, each inbound frame has exactly one owner for state mutation. Dual publication is permitted for observation tests, but dual application is not. Existing REST hydration and WebSocket paths remain functional throughout.
Testing
Required coverage includes exact and wildcard matching, unsubscribe, handler isolation, schema rejection, duplicate ids, stale and missing revisions, reconnect rehydration, scope isolation, command validation, Display sandbox boundaries and legacy adapter parity. Contract fixtures are shared between Django and TypeScript so topic and envelope changes fail CI on both sides.
The presence fixture is one envelope captured off a running stack, pinned
identically in agency-django/tests/contracts/agent_presence.json and
agency-ui/src/bus/contracts/agentPresence.json. Two repositories cannot
share a file, so they share a fixture: the producer test fails when Django
drifts and the consumer test fails when the UI does.
Transport inventory and current legacy frames: Connections.
The Display runtime has no Sphinx page yet. Until it does, its behaviour lives
in agency-django/src/agency/display/ and agency-ui/src/display/, with
the architecture plan in agency-workspace/todo/30-agency-display.md.