Contents

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.


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:

  1. Newline normalization across all text fields
  2. ChatType standardization to "direct" | "group" | "channel"
  3. BodyForAgent selection — priority chain: forced value → existing → CommandBody → RawBody → Body
  4. CommandAuthorized defaults to false — explicit deny-by-default
  5. 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:

  1. Dedup check — skip duplicate inbound messages
  2. message_received hook — fires async (void, parallel)
  3. Model resolutionbefore_model_resolve hook fires (modifying, sequential)
  4. Workspace + session initialization
  5. Media/link understanding applied
  6. Directive resolution — user commands like /think, /model
  7. before_prompt_build hook fires
  8. runPreparedReply() — assembles 40+ parameters for agent execution
  9. runReplyAgent() — 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 tools
  • followUp() — 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_end

streamAssistantResponse() — 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 (transformContextconvertToLlm) 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 broadcast

Each 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.