OpenClaw Core Flow Part 2: The Double Loop — Routing, Prompts & the Agent State Machine
Part 1 covered the gateway path from WebSocket to dispatchInboundMessage(). Now we trace what happens inside — from routing a message to the right agent, through system prompt construction, to the core LLM loop.
- Part 1: Gateway — WebSocket to Agent Dispatch
- Part 2 (this post): Routing, Prompts & the Agent State Machine
- Part 3: Tools, Memory & the Return Path
Channel → MsgContext Normalization
When a message arrives from any channel (WhatsApp, Telegram, Discord…), it’s normalized into MsgContext — a comprehensive type with 80+ fields defined in src/auto-reply/templating.ts:
type MsgContext = {
Body?: string; // Standard message content
BodyForAgent?: string; // May include envelope/history/context
Channel?: string; // "whatsapp", "telegram", etc.
From?: string; // Sender ID
ChatType?: string; // "direct" | "group" | "channel"
GroupId?: string;
ReplyToId?: string;
MediaPath?: string;
CommandAuthorized?: boolean; // Defaults to false (deny-by-default)
// ... 70+ more fields
};finalizeInboundContext() in src/auto-reply/reply/inbound-context.ts performs the normalization:
- Newline normalization across all text fields
- ChatType standardization to
"direct"|"group"|"channel" - BodyForAgent selection — priority chain: forced value → existing → CommandBody → RawBody → Body
- CommandAuthorized defaults to
false— explicit deny-by-default - Media handling — pads MediaTypes array to match media count, defaults to
application/octet-stream
8-Level Route Resolution
File: src/routing/resolve-route.ts
The routing engine evaluates bindings in strict priority order. First match wins:
const tiers = [
{ matchedBy: "binding.peer", // Tier 1: Direct peer match
predicate: (c) => c.match.peer.state === "valid" },
{ matchedBy: "binding.peer.parent", // Tier 2: Parent peer (thread → parent)
predicate: (c) => c.match.peer.state === "valid" },
{ matchedBy: "binding.guild+roles", // Tier 3: Discord guild + roles
predicate: (c) => hasGuildConstraint(c.match) && hasRolesConstraint(c.match) },
{ matchedBy: "binding.guild", // Tier 4: Guild without role
predicate: (c) => hasGuildConstraint(c.match) && !hasRolesConstraint(c.match) },
{ matchedBy: "binding.team", // Tier 5: Slack workspace
predicate: (c) => hasTeamConstraint(c.match) },
{ matchedBy: "binding.account", // Tier 6: Account (non-wildcard)
predicate: (c) => c.match.accountPattern !== "*" },
{ matchedBy: "binding.channel", // Tier 7: Channel wildcard
predicate: (c) => c.match.accountPattern === "*" },
];
// Tier 8: resolveDefaultAgentId(cfg) — global fallback
Bindings are pre-evaluated and cached per (channel, accountId) pair with a WeakMap + LRU eviction at 2000 keys.
Session Key Generation
Session keys follow patterns based on dmScope:
dmScope="main": agent:{id}:main
dmScope="per-peer": agent:{id}:direct:{peerId}
dmScope="per-channel-peer": agent:{id}:{channel}:direct:{peerId}
dmScope="per-account-channel-peer": agent:{id}:{channel}:{account}:direct:{peerId}Identity links enable cross-channel user merging. If Alice uses both Telegram and WhatsApp, identityLinks maps both IDs to the same canonical name — same session, same memory.
The Dispatch Pipeline
dispatchReplyFromConfig() orchestrates the full inbound flow:
- Dedup check — skip duplicate inbound messages
message_receivedhook — fires async (void, parallel)- Model resolution —
before_model_resolvehook fires (modifying, sequential) - Workspace + session initialization
- Media/link understanding applied
- Directive resolution — user commands like
/think,/model before_prompt_buildhook firesrunPreparedReply()— assembles 40+ parameters for agent executionrunReplyAgent()— the actual agent turn
OpenClaw → pi-agent-core Bridge
File: src/agents/pi-embedded-runner/run/attempt.ts
This is where OpenClaw calls into pi-coding-agent’s SDK:
const { builtInTools, customTools } = splitSdkTools({ tools, sandboxEnabled });
const { session } = await createAgentSession({
cwd: resolvedWorkspace,
model: params.model,
thinkingLevel: mapThinkingLevel(params.thinkLevel),
tools: builtInTools,
customTools: [...customTools, ...clientToolDefs],
sessionManager,
settingsManager,
});OpenClaw then subscribes to the session’s event stream:
const { unsubscribe } = subscribeEmbeddedPiSession({
session,
onBlockReply: ({ text, mediaUrls }) => { /* stream to user */ },
onToolResult: ({ text }) => { /* emit tool event */ },
onReasoningStream: ({ text }) => { /* stream thinking */ },
});Inside pi-agent-core: The Agent Class
File: packages/agent/src/agent.ts (pi-mono repo)
export class Agent {
private _state: AgentState;
private steeringQueue: AgentMessage[] = [];
private followUpQueue: AgentMessage[] = [];
private abortController?: AbortController;
async prompt(input) {
if (this._state.isStreaming) {
throw new Error("Agent is already processing. Use steer() or followUp().");
}
await this._runLoop(messages);
}
steer(m: AgentMessage) { this.steeringQueue.push(m); }
followUp(m: AgentMessage) { this.followUpQueue.push(m); }
abort() { this.abortController?.abort(); }
}Three control methods:
prompt()— start a new turn (blocked if already streaming)steer()— interrupt mid-run, delivered after current tool, skips remaining toolsfollowUp()— queue work for after the current run completes
The Double Loop
File: packages/agent/src/agent-loop.ts
This is the core state machine. It’s a nested loop — inner loop for tool calls + steering, outer loop for follow-ups:
OUTER LOOP (follow-ups): while(true) {
INNER LOOP (tool calls + steering): while(hasMoreToolCalls || pendingMessages) {
[1] Inject pending steering/follow-up messages into context
[2] streamAssistantResponse() → call LLM
[3] Check stopReason: error/aborted → exit
[4] Extract tool calls from response
[5] If tools: executeToolCalls() → check for steering between each
[6] Emit turn_end
[7] Poll getSteeringMessages()
}
[8] Check getFollowUpMessages()
if follow-ups exist → continue outer loop
else → break
}
emit agent_endstreamAssistantResponse() — The LLM Call
async function streamAssistantResponse(context, config, signal, stream) {
// 1. Apply transformContext (extensions modify context)
let messages = config.transformContext
? await config.transformContext(context.messages, signal)
: context.messages;
// 2. Convert AgentMessages to LLM Messages
const llmMessages = await config.convertToLlm(messages);
// 3. Stream from LLM
const response = await streamFn(config.model, { systemPrompt, messages: llmMessages, tools }, { signal });
// 4. Process streaming events
for await (const event of response) {
switch (event.type) {
case "start": stream.push({ type: "message_start", message: event.partial }); break;
case "text_delta": stream.push({ type: "message_update", ... }); break;
case "done": stream.push({ type: "message_end", message: finalMessage }); break;
}
}
}Message Conversion: convertToLlm()
Custom AgentMessage types are normalized to standard LLM Message[]:
| AgentMessage Role | Converted To |
|---|---|
user, assistant, toolResult |
Pass through unchanged |
bashExecution |
user with text representation |
branchSummary |
user with BRANCH_SUMMARY_PREFIX + summary |
compactionSummary |
user with COMPACTION_SUMMARY_PREFIX + summary |
custom |
user with normalized content |
This two-phase approach (transformContext → convertToLlm) lets extensions manipulate the rich AgentMessage[] format while maintaining LLM compatibility.
Tool Execution with Steering
async function executeToolCalls(tools, assistantMessage, signal, stream, getSteeringMessages) {
for (let i = 0; i < toolCalls.length; i++) {
const toolCall = toolCalls[i];
stream.push({ type: "tool_execution_start", ... });
try {
const validatedArgs = validateToolArguments(tool, toolCall);
result = await tool.execute(toolCall.id, validatedArgs, signal, (partial) => {
stream.push({ type: "tool_execution_update", partialResult: partial });
});
} catch (e) {
result = { content: [{ type: "text", text: e.message }] };
isError = true; // LLM sees error, can retry
}
stream.push({ type: "tool_execution_end", ... });
// STEERING CHECK between tool calls:
const steering = await getSteeringMessages();
if (steering.length > 0) {
// Skip remaining tools, inject steering message
for (const skipped of toolCalls.slice(i + 1)) {
results.push(skipToolCall(skipped));
}
return { toolResults: results, steeringMessages: steering };
}
}
}The steering check between tool calls is the key mechanism for user interruption. When a user sends a steering message, remaining tool calls are skipped (with placeholder results), and the new message is injected before the next LLM turn.
Error Recovery
| Error Type | Handling |
|---|---|
| Tool execution error | Caught, returned as toolResult with isError: true. LLM sees error and can retry. |
| LLM streaming error | Returns stopReason: "error". Inner loop exits. |
| Context overflow | Compaction system summarizes older messages in stages. Falls back to metadata-only if summarization fails. |
| Abort | AbortController.abort() propagates to tool execution and LLM streaming. Clean exit with stopReason: "aborted". |
| Validation error | validateToolArguments() failure returned as tool result — LLM retries with corrected args. |
Event Flow Summary
Agent._runLoop()
└─ agentLoop() → EventStream<AgentEvent>
└─ runLoop() — the double loop
├─ streamAssistantResponse()
│ ├─ transformContext()
│ ├─ convertToLlm()
│ └─ streamFn(model, context) → LLM API
│
└─ executeToolCalls()
└─ tool.execute() → per-tool results
└─ getSteeringMessages() → check for interrupts
Events flow:
agentLoop → EventStream → Agent._runLoop() → Agent.emit()
→ AgentSession._handleAgentEvent()
→ OpenClaw subscribeEmbeddedPiSession()
→ emitAgentEvent() → Gateway event bus
→ createAgentEventHandler() → WebSocket broadcastEach layer adds processing: session persistence, extension hooks, block reply chunking, usage tracking, 150ms throttle, and backpressure.
Next: Part 3 covers the tools layer — sandbox Docker execution, SSRF-protected web fetch, hybrid memory search with MMR re-ranking, and the 13-step hook execution order.
Analysis based on OpenClaw v2026.2.18 and pi-mono v0.53.0. Sources: openclaw/openclaw, badlogic/pi-mono. Research from cryptocj.org.