Connections
Real-time communication in Agency uses Django Channels behind nginx (ASGI). The database is always authoritative; WebSocket frames are projections — clients must rehydrate from REST after refresh or reconnect.
Routing lives in agency-server/framework/server/asgi.py. Producers:
agency.channel.protocol (Messenger, Monitor, Catalog), consumers under
agency.channel.consumer.
Inside the SPA, transport frames are normalized and routed by the namespaced
Agency Bus; socket paths and bus topics are related but
are not the same contract. Addressable UI nodes use entity_id
(channel + uuid [+ name]) as the bus topic — see
UI Schema. Live attrs ride that topic as
{ attrs: {…} } and rehydrate from GET /api/ui/overlay/.
Window pose is not an overlay key — it lives on the desktop snapshot.
Persisted bot panels are GET/POST/PATCH /api/ui/panels/; open is
panel.{uuid} via openAddress (REST document, not a WS-only tree).
Philosophy
Rule |
Meaning |
|---|---|
DB is source of truth |
Lists and detail reload via REST. |
WS is a projection |
Deltas only; may drop under load. |
Separate concerns |
Chat ≠ telemetry ≠ Celery ≠ agent-test chat. |
Auth-scoped groups |
HITL reaches |
Socket inventory
Path |
Consumer |
Groups joined |
Purpose |
|---|---|---|---|
|
|
|
Product socket: chat, HITL, observe |
|
|
|
Celery task monitor (System → Tasks) |
|
|
|
App log frames (prefer SSE |
|
|
|
Legacy all-thread telemetry |
|
|
|
Legacy one-thread telemetry |
|
|
ephemeral |
Agent test chat (Open chat explorer) |
|
|
|
Ephemeral public chat (no DB history) |
|
host chat app |
chat app |
Example app — not mission path |
Which socket for what?
Intent |
Socket |
|---|---|
Mission execute + HITL |
|
Live execution timeline |
Same |
Persistent secretary conversation |
REST |
Celery ops |
|
Quick agent chat test |
|
Public room chat |
|
Groups (publish side)
Group |
Who receives |
|---|---|
|
That user’s |
|
All authenticated user sockets |
|
Legacy thread consumers |
|
Task monitor clients |
|
Log stream consumers |
|
Members of that room socket |
Groups(Run) → threads + thread-{id} + user-{thread.user.id}.
Primary socket: /channel/
Two domain publishers
Messenger — conversation
Wire
type:messagePayload: serialized LangChain
BaseMessage(type/role/content/id)May include
ts,interruption,thread_id
Monitor — telemetry
Wire
type:monitor_eventFields:
event(canonical name),data, optionaltsExamples:
run_start,tool_call,run_end,interruption_resolved
UserConsumer text — UI affordances (not Messenger)
Wire
type:textWelcome, Action buttons, echo, interruption acks
Optional
databag (Action,Agent,Image, …)
Inbound frame types (server → client)
Chat and telemetry JSON with top-level type. Canonical bus frames are
recognised by v plus topic (no type field):
Frame |
Use |
|---|---|
|
Chat timeline (human / ai / tool) |
|
Plain or rich text + optional |
|
Run telemetry |
envelope |
Bus ( |
|
Schema mutations (UI) |
Outbound (client → server)
Chat:
{ "message": "hello" }
HITL answer:
{
"interruption": {
"id": "<uuid>",
"answer": { "message": "…", "text": "…" }
}
}
Action click:
{ "action": "click", "data": { "id": "…", "kwargs": { … } } }
Clients answer interruptions; resume runs via Celery on the server — do not call resume from the client on the happy path.
Catalog observe (bus envelope)
Listable rows stay live without polling:
observe(Model, resource=…, card=…)in Django (agency.channel.observe)Catalog.emit→publish_catalog_changed→ groupusersasagency_frameSPA
dispatchInboundMessage→ bus → catalog projector →applyDelta
Topic <resource>.<id>.catalog.<action>, kind state. Payload is the
same delta explorers already apply:
{
"v": 1,
"topic": "execution.<id>.catalog.created",
"kind": "state",
"payload": {
"resource": "execution",
"action": "created",
"id": "…",
"record": { },
"scope": { "workspace_id": "…" }
},
"subject": { "type": "execution", "id": "…" }
}
A leftover monitor_event / catalog_changed is ignored so it cannot
double-apply. Architecture: Agency Bus.
Desktop chrome (bus envelope)
open_window / close_window publish on user-{id}:
{
"v": 1,
"topic": "desktop.displays.visibility.changed",
"kind": "state",
"payload": { "open": true, "address": "displays" },
"subject": { "type": "window", "id": "displays" }
}
A Display name uses address: "display.<uuid>" and props.displayId.
The SPA projector calls openAddress; this is not a kiosk assignment.
show_media also publishes desktop.media.now_playing.changed (image or
YouTube) and opens the media window. YouTube plays only while that window
is visible.
Registered resources: agent, mission, execution, tool, model, provider, document.
Task monitor (/channel/task/)
Frames: welcome, task_started, task_progress, task_success, task_failure.
REST hydrate: GET /api/task/. Product UI: System → Tasks tab.
Agent test socket (/channel/agent/<uuid>/)
Separate from mission execution. Client sends:
{ "message": "…" }— text turn{ "message": "…", "photo_ids": "uuid,uuid" }— Photos already in the library (no base64 on the wire; drop from Photos / pins uses this){ "message": "…", "images": [{ "mime": "image/jpeg", "data": "<base64>" }] }— PC drops (JPEG/PNG/WebP; ≤1.5 MiB decoded each). Images apply to the current turn only; session history stays text. Requires the agent’s modelvision=True. Daphne allows 16 MiB frames (default was 1 MiB — two Photos PNGs closed the socket with 1006 before the consumer ran).{ "action": "reset" }— clear session history
Server replies: welcome (includes vision bool), processing
(has_images when applicable), event, stream_complete, reset, error.
Boot seeds a Portero greeter agent (prefers a vision model) for camera watch.
Ephemeral rooms (/channel/room/<slug>/)
Explicit exception to the “DB is source of truth” rule: rooms do not persist messages. The WebSocket is the only live state; reconnects lose backlog.
Authentication is required (anonymous connections close with code 4401).
Slug pattern: [-a-z0-9]+.
Wire protocol:
Client → server:
{ "message": "…" }Server → client message:
type=messagewithuser_id,username,messageServer → client presence:
type=presencewithcount,user_ids
Presence tracks unique authenticated users (same user in multiple tabs counts as 1). Broadcast on join and leave.
Note
Presence is tracked in-process on the consumer class. This works for a single ASGI worker; multi-process deployments would need Redis-backed presence.
Nginx already proxies /channel/* — no host change required.
HITL flow
Tool
ask_human→InterruptionPENDING + checkpointMessengerpublishes to user groupsSPA
HitlOverlay; user sends interruption answer on/channel/UserConsumersaves answer → signal →resume_mission(Celery)
Alternate: PATCH /api/interruption/<id>/ — same DB outcome.