Agency Bus ========== .. admonition:: Status — partially delivered :class: important This page describes both shipped behaviour and target architecture. Only the sections listed under :ref:`agency-bus-delivered` 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 :doc:`../../framework/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..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:: ... 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 :doc:`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.
.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..presence.changed delivered agent..face.changed delivered agent..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 :ref:`agency-bus-presence`. ``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..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: :doc:`../../tutorials/example-displays` (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. .. _agency-bus-delivered: 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.
.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.``, ``desktop.media``, ``agent.``). 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 ``..catalog.``, 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``. .. _agency-bus-presence: 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..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.
.visibility.changed`` (kind ``state``) on ``user-{id}``. Payload ``{"open": true, "address": "displays"}`` for shipped chrome, or ``address: "display."`` with ``props.displayId`` for a live Display preview (same as Displays → Open in window). A View name opens ``display-view.`` (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.`` — 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.`` — 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 ``.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: :doc:`../../framework/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``.