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.veronica or mission.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:

state

Functional state or a delta used to update a projection.

message

User-facing conversation content.

attention

A question, approval or notification that needs user awareness.

telemetry

Best-effort diagnostics. Losing it must not break product behaviour.

command

A client intention sent towards an authorized backend handler. Commands are never treated as facts and are not applied optimistically unless specified.

ack and error

Correlated 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 id supports 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.

Displays and shared properties

A DisplayScope is the authoritative shared-property boundary. A kiosk hydrates its assignment and property snapshot over REST, then receives display.scope.<id>.properties.changed deltas. The Desktop preview uses the same projector. The iframe SDK sees a smaller local API and never the complete application bus.

Shipped examples: Example display views (boot seed) (Counter, YouTube, Media image + Media picker on a shared image_url).

Property events contain scope revision plus per-property revisions. A mutation uses a client_event_id for idempotency and an expected revision when needed. The server persists the mutation before publishing its event. Multiple displays bound to one scope therefore converge on the same value.

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.ts

AgencyEnvelope types and parseEnvelope, which validates unknown data at runtime and returns null instead of throwing or casting.

topic.ts

Topic and pattern grammar, matchTopic with * and **, and a TopicIndex that buckets subscriptions by first segment so publication does not scan every subscriber.

bus.ts

createEventBus with publish/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.ts

Catalog explorer deltas. Django emits the envelope; there is no browser adapter.

projectors/presence.ts and stores/agentPresence.ts

Agent presence, delivered on the user-{id} group.

projectors/desktop.ts

Window open/close from open_window. Topic desktop.<address>.visibility.changed.

projectors/media.ts

Now-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 register source × target patterns; the most specific match wins. Components only declare their address via useDropTarget. 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 PC

  • photo.<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 (not desktop.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 Media

  • file.image × desktop.photos — upload, open Photos

  • photo.* × 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:

  1. introduce envelope types, topic parser and an in-memory bus with tests — done;

  2. place adapters in front of current dispatchInboundMessage behaviour — retired for catalog; Django now emits the envelope;

  3. 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;

  4. 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;

  5. secretary desktop chrome (open_window / close_window) — done;

  6. migrate conversation, interruption, secretary.notify and task projectors;

  7. adapt Display runtime and iframe bridge without weakening its sandbox;

  8. remove legacy conditionals only after contract and regression tests pass;

  9. 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.