Skip to main content
The ConversationManager (unify/conversation_manager/conversation_manager.py) is the live “front office”: a singleton that holds in-memory conversation state, consumes events, runs the reasoning loop, and delegates outbound I/O. Its package README describes it as the layer that interfaces with users while delegating complex reasoning to the Actor. Inbound event lifecycle: envelope → CommsManager → EventBroker → ConversationManager.wait_for_events → side effects → slow brain turn → comms tools / act / wait

The event model

Everything the runtime reacts to is a typed dataclass extending Event (unify/conversation_manager/events.py). Subclasses auto-register via __init_subclass__, serialize with to_json()/from_dict(), and carry behavioral flags — loggable (persist to transcripts), prominent, and suppress_slow_brain_wake (don’t schedule a brain turn for this event). The catalogue is broad and symmetric: for most channels there’s a *Received and a *Sent class (SMSReceived/SMSSent, EmailReceived/EmailSent, UnifyMessageReceived/UnifyMessageSent, and — for the org-installed Teams app — separate DM and channel pairs MsTeamsBotMessageReceived/MsTeamsBotMessageSent and MsTeamsBotChannelMessageReceived/MsTeamsBotChannelMessageSent…), plus call-lifecycle events (PhoneCallReceived, PhoneCallAnswered, PhoneCallEnded, UnifyMeetStarted…), per-utterance voice events (InboundPhoneUtterance, OutboundUnifyMeetUtterance…), reactions, voice-control events (FastBrainNotification, VoiceInterrupt), and lifecycle events (StartupEvent, TaskDue, PreHireMessage). Two mapping layers connect the world to handlers:
  1. Thread → event class. CommsManager.events_map in comms_manager.py turns an envelope’s thread key into an event instance, resolving the sender to a contact via MEDIUM_TO_CONTACT_FIELD and publishing to the in-process broker on app:comms:* topics.
  2. Event class → handler. EventHandler in domains/event_handlers.py is a registry: handlers are declared with @EventHandler.register(EventClass) and dispatched by EventHandler.handle_event(event, cm). A typical text-message handler logs to transcripts, updates the live ContactIndex, pushes a NotificationBar entry, and requests a brain run.

Mediums and modes

cm_types/medium.py defines the Medium enum — the single source of truth for channel types (UNIFY_MESSAGE, EMAIL, SMS_MESSAGE, WHATSAPP_MESSAGE, WHATSAPP_CALL, PHONE_CALL, UNIFY_MEET, GOOGLE_MEET, TEAMS_MEET, API_MESSAGE, Slack/Discord variants, and both the delegated-Graph (TEAMS_MESSAGE, TEAMS_CHANNEL_MESSAGE) and org-installed bot (MS_TEAMS_BOT_MESSAGE, MS_TEAMS_BOT_CHANNEL_MESSAGE) Teams mediums…). The two org-installed bot send tools are gated behind the assistant’s has_ms_teams_bot capability. Each medium registers a MediumInfo with a mode: TEXT, CALL, or MEET (cm_types/mode.py). Mode is what gates behavior — voice tools like guide_voice_agent only exist in voice sessions, and transcript rows pick their exchange from call state in voice modes. MEDIUM_TO_CONTACT_FIELD maps external mediums to the contact field used for identity resolution (phone_number, whatsapp_number, email_address, slack_user_id…).

The event loop and slow-brain scheduling

ConversationManager.wait_for_events() subscribes to the in-memory broker (patterns app:comms:*, app:actor:*, and friends), deserializes each message back into an Event, and dispatches through EventHandler. Handlers never call the LLM directly — they call request_llm_run(), which queues a request; flush_llm_requests() coalesces the queue and submits through a Debouncer (domains/utils.py) with queue-of-2 semantics: at most one running turn plus one pending, with user-origin requests outranking background ones. A single turn (_run_llm) is assembled from pure parts:
  • Renderer.render_state() (domains/renderer.py) snapshots the conversation into prompt text (and computes incremental diffs used when steering in-flight work);
  • build_brain_spec() (domains/brain.py) materializes the full inputs for one run — deliberately a small explicit structure so execution strategy can change without touching prompt construction;
  • the tool surface merges read-only inspection tools (ConversationManagerBrainTools in domains/brain_tools.py), action tools (ConversationManagerBrainActionTools in domains/brain_action_tools.py — every send_*, make_call, start_unify_meet, act, wait, guide_voice_agent…), and dynamic steering tools generated per in-flight action handle (task_actions.py: ask/stop/interject/pause/resume per handle).
The turn executes as a single tool-decision call; choosing wait ends the turn silently, which is how the assistant’s default “don’t spam” behavior is implemented.

The domains package

The domains/ directory is an ownership map — each module owns one concern:

Outbound: CommsPrimitives

unify/comms/primitives.py is the single implementation of assistant-owned outbound communication — the same CommsPrimitives class serves both the live brain’s action tools and primitives.comms.* inside Actor plans and offline task runs. Every send follows the same pipeline (using send_sms as the example):
  1. _check_outbound_allowed(contact) — the hard should_respond gate. A contact with should_respond=False cannot be messaged, period; the method returns an explanatory error string the brain must surface.
  2. Detail resolution — attach an inline phone/email to the contact if one was provided.
  3. HTTP to the gateway — via comms_utils.py (e.g. POST {COMMS_URL}/phone/send-text).
  4. Delivery proof — on success, a *Sent event (SMSSent) is published; its handler writes the transcript row. This is why the prompt-level rule “an outbound message isn’t real until its transcript row appears” holds — the row is the proof.
Outbound events published by the brain’s own sends set suppress_slow_brain_wake so the assistant isn’t re-woken by its own confirmations.

The API message round trip

The developer API channel is the one send path that doesn’t originate a conversation — it completes a caller that is already waiting, so it runs request/response rather than fire-and-forget:
  1. Inbound. Orchestra’s POST /v0/messages persists an ApiMessage row and dispatches it to the adapters, which publish an api_message envelope. CommsManager.events_map maps that thread to ApiMessageReceived, whose handler parks the api_message_id and the caller’s tags on the ConversationManager as _pending_api_message_id / _pending_api_message_tags.
  2. Outbound. send_api_response reads that pending id, so it is only meaningful while a call is in flight — with nothing pending it returns {"status": "ok", "note": "no pending api message"} rather than opening a new outbound conversation. It uploads any attachments, then comms_utils.complete_api_message() issues PUT {ORCHESTRA_URL}/messages/{id}/complete, which is what flips the caller’s poll from processing to completed.
  3. Record. An api_message_sent event writes the transcript row, anchored to the boss contact — the API exchange lands in the same durable history as every other medium (Medium.API_MESSAGE).
Tags are opaque to the runtime; the prompt tells the brain to echo them back by default so the developer can route the reply on their own side.

Transcripts

unify/transcript_manager/transcript_manager.py owns the durable record: Message rows (medium, sender, receivers, content, attachments) grouped into Exchange threads (types/message.py, types/exchange.py). The runtime writes through managers_utils.log_message(), which infers medium and role from the event class, resolves the exchange (from call state for voice), and calls TranscriptManager.log_messages(). Voice utterances are stored with call-relative timestamps. These are the same rows the Console’s Transcripts pane renders.

Steering and interruptions

Communication interacts with the steering machinery in two directions. Downward: every in-flight act(...) is a steerable handle the brain can ask, interject, pause, resume, or stop via dynamically generated tools. Upward: when a user speaks while the slow brain is mid-turn, the input does not cancel the running turn. The Debouncer (domains/utils.py) holds one running turn plus at most one pending turn — a new submission replaces whatever was pending and starts when the current turn finishes. Preemption used to exist (a SpeechUrgencyEvaluator that classified the utterance and cancelled the running turn) and was removed: the queue of two is the whole mechanism. What makes interruption feel immediate on a call is the fast brain — barge-in and the interim turn — not turn cancellation; see Voice calls.