Aleph
Concepts

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:

  1. 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 }.
  2. MessageContent (src/session/events.rs) — a message's body inside the session_events log; text plus optional blocks: Vec<serde_json::Value> plus optional thinking / thinking_signature.
  3. AlephEvent (src/event/types.rs) — the system-wide event-bus payload, covering InputReceived / 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 response

Channel 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:

ParadigmDescriptionDefault Capabilities
CLITerminal interfaceRichText, CodeHighlight, Streaming
WebRichFull web interfaceRichText, CodeHighlight, MultiGroupUI, Streaming, MermaidCharts, ImageInline, Canvas
MessagingChat platforms (Telegram, Discord, iMessage)RichText, ImageInline
BackgroundScheduled/cron tasksSilentReply
EmbeddedConstrained environmentsEmpty 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:

VariantSerialized Form ExampleUse Case
Mainagent:main:mainCross-channel shared session
DirectMessageagent:<id>:<channel>:dm:<peer>Per-user DM
Groupagent:<id>:<channel>:group:<peer>[:thread:<id>]Group/channel chat
Taskagent:<id>:task:<type>:<id>Cron, webhooks
Subagentsubagent:<parent>:<id>Sub-agent delegation
Ephemeralagent:<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-writtensession_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:

  1. Extract key facts from older messages.
  2. Store the extracted facts in the memory system for long-term retrieval.
  3. Replace old messages with a concise summary.
  4. 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 / EventPayload
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..."
    }
  }
}

26.7.x Addendum

Session-Event Single-Source-of-Truth

26.7.7 introduces session_events as the canonical event log:

  • MessageProjector materializes SessionEvent records into the messages table
  • A boot-time ProjectionReconciler back-fills transcripts
  • Messages carry tool_call_id / tool_name + an AssistantMessage token stamp
  • Legacy sessions are back-filled and read behind a locked invariant; the messages table is no longer dual-written

UnifiedMessage and ProviderDelta

26.7.7+ in src/providers/ introduces UnifiedMessage and ProviderDelta:

  • UnifiedMessage is the LLM-agnostic intermediate message representation (OpenAI / Anthropic / Gemini / Ollama all adapt to it), with three variants: User / Assistant / ToolResult
  • ProviderDelta is the streaming output (TextDelta / ThinkingDelta / ThinkingSignatureDelta / ToolCallStart / ToolCallArgDelta / ToolCallArgsComplete / ToolCallEnd / Usage / Done / Error)
  • A single DeltaCollector reducer 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 steps summary 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_unordered completion-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

On this page