Messages
How messages flow through Aleph from any interface to the agent and back, including message types, routing rules, session threading, and persistence.
Messages are the fundamental unit of communication in Aleph. Whether a user sends a text via Telegram, types a command in the CLI, or submits a prompt through the desktop app, every interaction is normalized into Aleph's unified message format before reaching the agent. This abstraction allows the same agent brain to serve every interface without modification.
Message Types
Aleph uses three related but distinct message representations at different boundaries:
UnifiedMessage(src/providers/message.rs) — the LLM-agnostic intermediate representation; protocol adapters convert it to OpenAI / Anthropic / Gemini / Ollama native shapes. Three variants:User { content },Assistant { content },ToolResult { tool_call_id, tool_name, content, is_error }.MessageContent(src/session/events.rs) — a message's body inside thesession_eventslog;textplus optionalblocks: Vec<serde_json::Value>plus optionalthinking/thinking_signature.AlephEvent(src/event/types.rs) — the system-wide event-bus payload, coveringInputReceived/ToolCallRequested/ToolCallStarted/ToolCallCompleted/ToolCallFailed/ToolCallRetrying/LoopContinue/LoopStop/SessionCreated/SessionUpdated/SessionResumed/SessionCompacted/SubAgentCompleted/SubAgentTreeUpdate/AiResponseGenerated/PartAdded|Updated|Removed/Team*.
Content Blocks (UnifiedMessage)
Assistant messages may carry multiple ContentBlocks, supporting rich multimodal responses (src/providers/message.rs):
{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "Let me analyze this code...", "signature": "..." },
{ "type": "text", "text": "Here is the refactored version:" },
{ "type": "tool_call", "id": "call_1", "name": "file_write", "arguments": { "path": "main.rs", "content": "..." }, "thought_signature": null }
]
}ContentBlock has five variants: Text { text, cache_control? }, Json { value }, Thinking { thinking, signature? }, ToolCall { id, name, arguments, thought_signature? }, Image { data, mime_type }. The thinking and thought_signature fields are used by providers like Anthropic and Gemini 3 to replay signed blocks verbatim to subsequent turns; providers without signing leave them empty.
Message Routing
When a message arrives from any interface, it follows a precise routing path through the Gateway to the agent:
User sends message (e.g., Telegram)
│
▼
Channel Layer (Telegram Bot → InboundMessage)
│ Normalize to InboundMessage { id, channel_id, conversation_id, sender_id, text, attachments, ... }
▼
Gateway: Inbound Router (inbound_router)
│ Parse JSON-RPC; auth, pairing, DM/group policy gate
▼
SessionKey resolution (src/routing/session_key.rs)
│ SessionKey::Main | DirectMessage | Group | Task | Subagent | Ephemeral
▼
Session Manager (SessionActor)
│ Load session_events history, append the new message
▼
Orchestrator::dispatch(FlowRequest)
│ FlowRequest { agent_id, input, channel, session_hint?, parent_session?, depth, tool_service?, trace_sink?, interaction_manifest?, ... }
▼
HarnessRunner::run(session_key, spec, input, sandbox, events, cancel, ...)
│ Observe → Think → Act → Feedback
▼
Gateway: Outbound Emitter (GatewayEventBus)
│ Subscribe FlowStreamEvent and broadcast to clients
▼
Channel Layer
│ OutboundMessage / Channel::send / NativeStreamHandler
▼
User receives responseChannel Abstraction
Each interface implements the Channel trait (src/gateway/channel.rs), normalizing platform-specific messages into a common format:
#[async_trait]
pub trait Channel: Send + Sync {
fn info(&self) -> &ChannelInfo;
fn state(&self) -> &ChannelState;
async fn start(&mut self) -> ChannelResult<()>;
async fn stop(&mut self) -> ChannelResult<()>;
async fn send(&self, message: OutboundMessage) -> ChannelResult<SendResult>;
// ...
}Core data structures on the channel side: ChannelId / ConversationId / UserId / MessageId (newtype strings), InboundMessage / OutboundMessage, ChannelCapabilities (attachments / images / audio / video / reactions / replies / editing / deletion / typing_indicator / read_receipts / rich_text / max_message_length / max_attachment_size / stream_protocol), ChannelStatus, and StreamProtocol::{None, EditBased, Native}.
Interaction Manifests
Each channel declares its capabilities to the Thinker (src/thinker/interaction.rs):
InteractionManifest::new(InteractionParadigm::Messaging) // defaults: RichText + ImageInline
.with_capabilities(my_set) // override the default set
.with_constraints(
InteractionConstraints::new()
.max_output_chars(4096)
.supports_streaming(true)
.prefer_compact(false)
);Available paradigms:
| Paradigm | Description | Default Capabilities |
|---|---|---|
CLI | Terminal interface | RichText, CodeHighlight, Streaming |
WebRich | Full web interface | RichText, CodeHighlight, MultiGroupUI, Streaming, MermaidCharts, ImageInline, Canvas |
Messaging | Chat platforms (Telegram, Discord, iMessage) | RichText, ImageInline |
Background | Scheduled/cron tasks | SilentReply |
Embedded | Constrained environments | Empty set |
The full Capability set: RichText / InlineButtons / MultiGroupUI / Streaming / ImageInline / MermaidCharts / CodeHighlight / FileUpload / Canvas / SilentReply / NativeIdentity. paradigm_for_channel_type at the bottom of src/gateway/channel.rs gives the channel-string → paradigm lookup (cli → CLI, telegram|feishu|slack|whatsapp|discord|irc|msteams|wechat|line → Messaging, webchat|webrich|web|webhook → WebRich, unknown falls back to Background).
Session Threading
Aleph uses a hierarchical SessionKey enum (src/routing/session_key.rs) to isolate conversations across channels, users, and contexts — not free-form strings, but a structured enum.
SessionKey Variants
pub enum SessionKey {
Main { agent_id, main_key, epoch },
DirectMessage { agent_id, channel, peer_id, dm_scope, epoch },
Group { agent_id, channel, peer_kind: PeerKind, peer_id, thread_id? },
Task { agent_id, task_type, task_id },
Subagent { parent_key: Box<Self>, subagent_id },
Ephemeral { agent_id, ephemeral_id },
}SessionKey::Display serializes each variant to a path-style string for human reading and persistence:
| Variant | Serialized Form Example | Use Case |
|---|---|---|
| Main | agent:main:main | Cross-channel shared session |
| DirectMessage | agent:<id>:<channel>:dm:<peer> | Per-user DM |
| Group | agent:<id>:<channel>:group:<peer>[:thread:<id>] | Group/channel chat |
| Task | agent:<id>:task:<type>:<id> | Cron, webhooks |
| Subagent | subagent:<parent>:<id> | Sub-agent delegation |
| Ephemeral | agent:<id>:ephemeral:<uuid> | Temporary session |
DM Scope Strategies
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum DmScope {
Main, // All DMs share the main session
#[default]
PerPeer, // Isolated per user (default)
PerChannelPeer, // Isolated per channel + user
}The default PerPeer strategy means each person chatting with Aleph gets their own conversation history, regardless of which platform they use. SessionKey::dm collapses to Main when dm_scope == Main, so all DMs share a single session.
Message Persistence
Session events are appended as SessionEventRecord { seq, event, created_at_ms } (src/session/events.rs) into the session_events table. MessageProjector (src/gateway/session_projector.rs) observes the event stream and materializes the messages table; at boot, ProjectionReconciler back-fills legacy sessions. Both tables are no longer dual-written — session_events is the single source of truth (SSOT); messages is a read-only projection.
Session Compaction
When a session accumulates too many tokens (exceeding the configured threshold), the Session Manager triggers compaction:
- Extract key facts from older messages.
- Store the extracted facts in the memory system for long-term retrieval.
- Replace old messages with a concise summary.
- Update the token count.
This process is transparent to the user. The agent retains awareness of the full conversation through the summary and fact store.
Event Streaming
As the agent processes a message, it emits FlowStreamEvent (src/orchestrator/dispatch.rs) in real time via GatewayEventBus. Clients subscribe over JSON-RPC events.subscribe:
{
"jsonrpc": "2.0",
"method": "events.subscribe",
"params": { "pattern": "stream.*" },
"id": 1
}FlowStreamEvent variants (the subset the panel/client primarily consumes):
| Topic / Event | Payload |
|---|---|
Delta(text) | Incremental assistant text |
Reasoning(text) | Thinking fragment (when the provider returns one) |
ToolCallStart { id, name, args } | Tool call started |
ToolCallDone { id, result?, error?, duration_ms } | Tool call finished (completion order, not input order) |
ContextGauge { context_tokens, context_window, total_tokens } | Live context-window occupancy |
SafetyBlock { reason } | Safety gate blocked the turn |
Complete(FlowOutcome) | Terminal event, always last |
The underlying AlephEvent bus also carries InputReceived / ToolCallRequested / ToolCallRetrying / SubAgentCompleted / SubAgentTreeUpdate / Team* for monitors and cross-agent routing.
Security Context
Each message carries an identity context that flows through the entire execution chain (shared/protocol/src/auth.rs):
pub struct IdentityContext {
pub request_id: String,
pub session_key: String,
pub role: Role, // Owner, Guest, Anonymous
pub identity_id: String,
pub scope: Option<GuestScope>,
pub created_at: i64,
pub source_channel: String,
}The identity context is immutable once created and determines what tools the agent can invoke on behalf of the user. Owner sessions have unrestricted access, while Guest sessions are limited to the tools listed in their GuestScope. See the Security section for details on the permission model.
JSON-RPC Protocol
All message exchange between clients and the Gateway uses JSON-RPC 2.0 over WebSocket. The actual chat.send parameter shape (src/gateway/handlers/chat.rs):
{
"jsonrpc": "2.0",
"id": 1,
"method": "chat.send",
"params": {
"message": "Explain the Rust borrow checker",
"session_key": "agent:main:main",
"channel": "cli:term1",
"stream": true,
"thinking": null,
"attachments": [],
"agent_id": null,
"project_root": null,
"model_override": null,
"exec_tier": null,
"mode": null,
"voice_input": false
}
}Field notes: stream defaults to true; thinking / mode / exec_tier are all Option<String> (no inline defaults, and the field is mode rather than a separate session_mode); mode is picked in the Panel composer as chat / work / code and carried on the first message to prevent overrides from being silently reverted.
Receiving a streamed response:
{
"jsonrpc": "2.0",
"method": "event",
"params": {
"topic": "stream.chunk",
"data": {
"run_id": "run-123",
"content": "The borrow checker is Rust's..."
}
}
}Related Pages
- Gateway Protocol -- WebSocket protocol and event bus
- Agent Runtime -- How the agent processes messages
- Workspaces -- Session storage and state directories
26.7.x Addendum
Session-Event Single-Source-of-Truth
26.7.7 introduces session_events as the canonical event log:
MessageProjectormaterializesSessionEventrecords into themessagestable- A boot-time
ProjectionReconcilerback-fills transcripts - Messages carry
tool_call_id/tool_name+ anAssistantMessagetoken stamp - Legacy sessions are back-filled and read behind a locked invariant; the
messagestable is no longer dual-written
UnifiedMessage and ProviderDelta
26.7.7+ in src/providers/ introduces UnifiedMessage and ProviderDelta:
UnifiedMessageis the LLM-agnostic intermediate message representation (OpenAI / Anthropic / Gemini / Ollama all adapt to it), with three variants:User/Assistant/ToolResultProviderDeltais the streaming output (TextDelta/ThinkingDelta/ThinkingSignatureDelta/ToolCallStart/ToolCallArgDelta/ToolCallArgsComplete/ToolCallEnd/Usage/Done/Error)- A single
DeltaCollectorreducer owns the assembled message and stitches thinking signatures live - Streaming and final text both route through one shared sanitize atom
Chat Stream
26.7.7 introduced the narration-led chat stream:
- Compact single-line tool rows (status glyphs, live elapsed)
✓N stepssummary line once done- The workspace pane becomes a tool-detail viewer that live-follows the foreground conversation (with pin)
- Tool events are emitted in completion order (not input order) via the
buffer_unorderedcompletion-driven loop
Mid-Stream Steering
26.6.29+ supports typing into a chat while it's still running:
- Queued messages render as in-stream "ghost" bubbles
- Flushed at the next turn boundary via steering, or force-inserted immediately (Esc + ⚡)
- Injecting a message no longer produces an illegal message sequence; tool results stay contiguous, so OpenAI-compatible proxies no longer silently return empty responses
iMessage Tapback
26.7.21+: inbound iMessage add-tapbacks (add-reactions) are surfaced to the model as context (carried via InboundMessage::metadata) rather than dropped.
See Also
- Gateway Protocol — JSON-RPC and the event bus
- Session Mode — the
modefield - Thinker — where
UnifiedMessage→ProviderDeltaactually lives