Message Lifecycle ๐ก
From a user sending a message on Telegram to an AI reply appearing โ what happens in between? This chapter traces every step along that path.
Learning Objectives
After reading this chapter, you'll be able to:
- Draw the complete data flow diagram from inbound to outbound
- Understand the
InboundEnvelopeformat and its role in the system - Explain status feedback (typing indicators, message status) and how it's implemented
- Understand
SILENT_REPLY_TOKENand the Heartbeat system
I. The Complete 5-Phase Lifecycle
flowchart TB
subgraph PHASE1["๐ต Phase 1: Receive"]
TG_MSG["User sends message on Telegram"]
BOT_RECV["Telegram Bot receives Update"]
CH_PARSE["Channel plugin parses (parseUpdate)"]
end
subgraph PHASE2["๐ข Phase 2: Standardize"]
ENVELOPE["Build InboundEnvelope\n(channel/from/body/route)"]
STATUS["Send status feedback\n(typing indicator)"]
end
subgraph PHASE3["๐ก Phase 3: Route"]
ROUTE["resolveAgentRoute()\nโ which Agent handles this?"]
SESSION["Load/create session\n(Session Key calculation)"]
end
subgraph PHASE4["๐ด Phase 4: Reason"]
BOOTSTRAP["Build Bootstrap context\n(System Prompt + Memory + Tools)"]
LLM["Call LLM\n(streaming inference)"]
TOOLS["Tool call loop\n(bash/files/MCP etc.)"]
end
subgraph PHASE5["๐ฃ Phase 5: Send"]
REPLY_TEXT["AI reply text\n(streamed incrementally)"]
CH_SEND["Channel plugin sends\n(sendMessage)"]
TG_REPLY["User receives reply"]
end
PHASE1 --> PHASE2 --> PHASE3 --> PHASE4 --> PHASE5
TG_MSG --> BOT_RECV --> CH_PARSE --> ENVELOPE --> STATUS
STATUS --> ROUTE --> SESSION --> BOOTSTRAP --> LLM --> TOOLS --> REPLY_TEXT --> CH_SEND --> TG_REPLY
II. Phase 1: Receiving Messages
Channel plugins receive messages in one of two ways:
Webhook (for platforms that support server-push, like Telegram and Discord):
User sends message
โ Messaging platform (e.g., Telegram servers)
โ HTTP POST to OpenClaw Gateway (/webhook/telegram/...)
โ Plugin route handler registered in server-http.ts
โ Channel plugin's handleWebhook(req, res)
Long Polling (for platforms that don't support webhooks):
Channel plugin actively polls the platform API
โ Receives message Update
โ Processes it internally
The ChannelManager in server-channels.ts manages the lifecycle (start, stop, restart) of all channels and maintains their runtime state for display in the Control UI.
III. Phase 2: Building the InboundEnvelope
This is the critical interface between the channel layer and the core layer.
After receiving a raw platform message, the channel plugin builds an InboundEnvelope โ a standardized "inbound packet" containing everything the core needs, while hiding all platform-specific protocol differences.
// src/plugin-sdk/inbound-envelope.ts
type InboundEnvelope = {
// Source
channel: string; // e.g., 'telegram'
accountId: string; // Bot account ID
peer: RoutePeer; // { kind: 'dm', id: '123456789' }
// Message content
text?: string;
media?: MediaAttachment[]; // Images, files, audio
// Platform context
guildId?: string; // Discord server / equivalent
teamId?: string; // Slack workspace / equivalent
memberRoleIds?: string[]; // Discord role IDs
// Threading
threadId?: string;
parentPeer?: RoutePeer;
// Metadata
messageId?: string;
timestamp?: Date;
};
The envelope's body field is pre-formatted text containing the timestamp, source channel, and context information, ready to be appended directly into the Agent's conversation history.
Status Feedback: Typing Indicators
Good UX requires immediate feedback after a user sends a message ("AI is thinking..."). This is implemented through status reactions:
// Conceptual โ src/channels/status-reactions.ts
await channel.status.typing(); // Show typing indicator
// ... AI reasoning runs ...
await channel.status.done(); // Clear indicator
- Telegram:
sendChatAction({ action: 'typing' }) - Discord:
interaction.deferReply() - Slack: React with a clock emoji on the message
The interface is unified; implementations differ per platform.
IV. Phase 3: Routing (Overview)
See Data Flow 02 โ Routing Engine for the deep dive. Here's the summary:
// src/routing/resolve-route.ts
const route = resolveAgentRoute({
channelId: 'telegram',
accountId: '@username',
peer: { kind: 'dm', id: 123456789 },
cfg,
});
// โ { agentId: 'main', sessionKey: 'agent:main:telegram/default/dm/123456789' }
The sessionKey is the unique identifier for a conversation. All history is stored and retrieved through it.
V. Phase 5: Sending Replies
AI reply text is streamed to channel plugins via EventFrame events:
sequenceDiagram
participant AGENT as "Agent reasoning"
participant GW as "Gateway\nserver-channels.ts"
participant CH as "Channel plugin\n(Telegram)"
participant USER as "User"
AGENT->>GW: text_delta event ("Hello")
GW->>CH: onReplyDelta("Hello")
AGENT->>GW: text_delta event (", how")
GW->>CH: onReplyDelta(", how")
AGENT->>GW: text_delta event (" can I help?")
GW->>CH: onReplyDelta(" can I help?")
AGENT->>GW: reply_done event
GW->>CH: onReplyDone()
CH->>USER: Send Telegram message
Streaming vs Batch Sending:
| Channel | Streaming Strategy |
|---|---|
| Telegram | No native streaming โ send complete reply (typing indicator fills the wait) |
| Discord | Edit message progressively (simulate streaming via edits) |
| Slack | Update Block Kit message incrementally |
| CLI | Direct stdout character-by-character streaming |
| Web UI | Full streaming via WebSocket token-by-token |
Channel plugins implement ChannelOutboundAdapter to handle these differences.
VI. SILENT_REPLY_TOKEN
Not every AI response should be sent to the user. SILENT_REPLY_TOKEN lets the Agent silently suppress a reply:
// src/auto-reply/tokens.ts
When the AI returns [SKIP_REPLY], the Gateway recognizes it and sends nothing to the channel. Used for:
- Passive monitoring mode (AI sees messages but doesn't respond)
- Conditional triggers (AI decides no reply is warranted)
- Heartbeat ACK (internal confirmation only, no user notification needed)
VII. Heartbeat System
Heartbeat is a key OpenClaw feature: even without user messages, the AI periodically "wakes up" to check for tasks and can proactively send messages.
flowchart LR
CRON["Cron scheduler\nserver-cron.ts\n(every N minutes)"]
HB_MSG["Construct heartbeat message\n('Heartbeat: check status')"]
AGENT["Agent reasoning\n(receives heartbeat trigger)"]
REPLY{"AI decision"}
SILENT["Return SILENT_REPLY_TOKEN\n(nothing to do)"]
ACT["Take action\n(send alert / execute task)"]
CRON --> HB_MSG --> AGENT --> REPLY
REPLY -->|"nothing to do"| SILENT
REPLY -->|"something to do"| ACT
Heartbeat messages are hidden from the user (shouldHideHeartbeatChatOutput() filters them), but they trigger full AI reasoning โ giving the AI the opportunity to proactively execute tasks (e.g., "send a weather summary every morning at 9am").
VIII. Session Storage
All messages (user + AI turns) are persisted, organized by sessionKey:
~/.config/openclaw/sessions/
โโโ <agentId>/
โโโ <sessionKey>/
โโโ session.json โ session metadata (created, last updated)
โโโ messages/ โ message history (time-ordered)
โโโ 0001.json
โโโ 0002.json
โโโ ...
The src/sessions/ module handles read/write via SQLite (better-sqlite3).
Key Source Files
| File | Size | Role |
|---|---|---|
src/plugin-sdk/inbound-envelope.ts | 4.7KB | createInboundEnvelopeBuilder() |
src/gateway/server-channels.ts | 20KB | Channel management and inbound scheduling |
src/gateway/server-chat.ts | 27KB | WebSocket chat handling, streaming reply push |
src/channels/status-reactions.ts | โ | Status feedback (typing indicators) |
src/auto-reply/tokens.ts | โ | SILENT_REPLY_TOKEN definition |
src/auto-reply/heartbeat.ts | 172 lines | Heartbeat token processing |
src/gateway/server-cron.ts | 17KB | Cron job scheduling |
src/sessions/ | โ | Session persistence (SQLite) |
Summary
- Five phases: receive โ standardize โ route โ reason โ send; each phase has a single responsibility.
InboundEnvelopeis the channel-to-core interface: standardized format that hides all platform protocol differences.- Status feedback is UX, not an afterthought: typing indicators fire immediately when reasoning starts.
SILENT_REPLY_TOKENenables "silent mode": the AI can choose not to respond โ for passive listening, conditional triggers, and heartbeat ACKs.- Heartbeat makes AI proactive: timed triggers let the AI send messages on its own schedule.
- Session history persists locally: all messages organized by
sessionKeyin a local SQLite database.