OpenClaw: Prompt Construction & Agent Loop — Deep Analysis

System prompt lineage, string component origins, LLM API calling, and parallelism handling

Table of Contents

1. Architectural Overview

Harness Layer Agent Harness Registry selection.ts · builtin-pi.ts
Runner Layer pi-embedded-runner compact.ts · model.ts
Run Attempt Layer run/attempt.ts 3290 lines · full pipeline
Streaming Layer Stream Wrappers stream-wrapper.ts

Layer Dependency Graph

CHANNEL LAYER HARNESS LAYER RUNNER LAYER STREAM LAYER Telegram Discord Web / CLI Cron / Subagent selection.ts · harness/ AgentHarness selection + fallback builtin-pi.ts · v2.ts PI harness + V2 adapter compact.ts 1,287 lines · session compaction context-window-guard · MCP tools model.ts 1,121 lines · model resolution PiModelRegistry · auth discovery run/attempt.ts 3,290 lines · core run pipeline setup · payload · session yield system-prompt.ts 1,048 lines · prompt builder context.ts (21KB) stream-wrapper.ts idle timeout · diagnostics · stream-resolution.ts WebSocket · Vertex · provider-stream.js registerProviderStream · prompt-cache Google · observability

2. Prompt Construction — String Component Lineage

The system prompt is assembled in src/agents/system-prompt.ts via buildAgentSystemPrompt() — a 1,048-line function that composes 20+ string sections from a dozen source files. Below is the complete component tree.

2.1 System Prompt Component Assembly Tree

buildAgentSystemPrompt() Identity Header Tooling Section Safety Section Skills Section Memory Section Docs Section Workspace Section OpenClaw CLI Ref Model Aliases Self-Update Section Tool Call Style Execution Bias Interaction Style ACP Routing Runtime Info Line Sandbox Section User Identity Time Section Project Context Heartbeat Section Silent Replies Group Chat Context Reactions Reasoning Format Dynamic Context SOURCE FILES → PROMPT COMPONENTS system-prompt.ts buildAgentSystemPrompt() · 1,048 lines Lines 1-500: helpers, identity, tooling context.ts buildMemorySection() · 21,913 bytes memory/citations · project context bootstrap-prompt.ts buildFullBootstrapPromptLines() · 25 lines bootstrap files injection pi-embedded-runner/system-prompt.ts buildEmbeddedSystemPrompt() · 122 lines wraps buildAgentSystemPrompt() pi-embedded-runner/run/attempt.ts Lines 1000-1200: systemPromptText assembly hooks · transformProviderSystemPrompt .pi/prompts/cl.md + is.md cl.md: 2,551 chars · is.md: 964 chars Loaded by bootstrap-prompt.ts COLOR CODING: Prompt Sections Context/Memory Tool/Style Sandbox/User Special/Dynamic

2.2 Section Composition Detail

buildAgentSystemPrompt() — Section Assembly Order (Lines 702–1003)

lines = [
  // 1. Identity
  "You are a personal assistant running inside OpenClaw.",

  // 2. Tooling (lines 705–729)
  "## Tooling",
  toolLines.join("
"),    // ← from buildToolsSection(): toolNames + descriptions

  // 3. Tool Call Style (lines 747–765)
  "## Tool Call Style",
  ...buildOverridablePromptSection({ override: providerSectionOverrides.tool_call_style, fallback: [...] }),

  // 4. Execution Bias (lines 767–771)
  "## Execution Bias",
  ...buildOverridablePromptSection({ override: providerSectionOverrides.execution_bias, fallback: [...] }),

  // 5. Safety (lines 672–678)
  "## Safety",
  ...safetySection,   // hardcoded safety principles

  // 6. OpenClaw CLI Quick Reference (lines 777–788)
  "## OpenClaw CLI Quick Reference",
  ...,

  // 7. Skills Section (lines 679–682, 790)
  ...buildSkillsSection({ skillsPrompt, readToolName }),

  // 8. Memory Section (lines 683–688, 791)
  ...buildMemorySection({ isMinimal, includeMemorySection, availableTools, citationsMode }),

  // 9. OpenClaw Self-Update (lines 793–802)
  "## OpenClaw Self-Update",
  hasGateway && !isMinimal ? [...self-update guidance] : "",

  // 10. Model Aliases (lines 806–815)
  "## Model Aliases",
  ...params.modelAliasLines,

  // 11. Workspace (lines 819–822)
  "## Workspace",
  `Your working directory is: ${displayWorkspaceDir}`,
  workspaceGuidance,
  ...workspaceNotes,

  // 12. Docs Section (lines 689–694, 824)
  ...buildDocsSection({ docsPath, sourcePath, isMinimal }),

  // 13. Sandbox Section (lines 825–886)
  "## Sandbox",
  sandboxInfo.enabled ? [...sandbox config details] : "",

  // 14. User Identity (line 888)
  ...buildUserIdentitySection(ownerLine, isMinimal),

  // 15. Time Section (lines 889–891)
  ...buildTimeSection({ userTimezone }),

  // 16. Project Context (lines 892–983)
  ...buildProjectContextSection({ files: stableContextFiles, dynamic: false }),
  SYSTEM_PROMPT_CACHE_BOUNDARY,  // ← cache invalidation seam
  ...buildProjectContextSection({ files: dynamicContextFiles, dynamic: true }),

  // 17. Extra System Prompt / Group Chat (lines 985–993)
  extraSystemPrompt ? "## Subagent Context" / "## Group Chat Context" : "",
  extraSystemPrompt,
  providerDynamicSuffix,

  // 18. Heartbeat (line 995)
  ...buildHeartbeatSection({ isMinimal, heartbeatPrompt }),

  // 19. Runtime (lines 997–1001)
  "## Runtime",
  buildRuntimeLine(runtimeInfo, ...),
  `Reasoning: ${reasoningLevel}`,

  // 20. Silent Replies (lines 955–970)
  "## Silent Replies",
  SILENT_REPLY_TOKEN usage,

  // 21. Reactions (lines 912–934, conditional)
  // 22. Reasoning Format (lines 935–937, conditional)
]

2.3 Provider Override System — Overridable Prompt Sections

The system uses buildOverridablePromptSection() pattern throughout — each major section can be overridden by a plugin provider's ProviderSystemPromptContribution:

SectionOverride KeyFallback SourceFile
Interaction Style providerSectionOverrides.interaction_style Empty array (no default) system-prompt.ts:743
Tool Call Style providerSectionOverrides.tool_call_style Lines 748–764: narration guidance, exec approval system-prompt.ts:747
Execution Bias providerSectionOverrides.execution_bias buildExecutionBiasSection() system-prompt.ts:767
Stable Prefix providerStablePrefix Empty array system-prompt.ts:772
Dynamic Suffix providerDynamicSuffix Empty string system-prompt.ts:991–993

3. String Components: Source Files Index

String / SectionDefined InCharsType
"You are a personal assistant running inside OpenClaw." system-prompt.ts:703 ~52 Hardcoded identity
## Safety — 4-line safety constitution system-prompt.ts:672-678 ~300 Hardcoded safety
## Tool Call Style — narration/exec approval guidance system-prompt.ts:747-765 ~400 Policy guidance
## Execution Bias — auto-complete bias system-prompt.ts:768-770 ~200 Provider policy
## OpenClaw CLI Quick Reference system-prompt.ts:777-788 ~400 CLI commands
## OpenClaw Self-Update system-prompt.ts:793-802 ~300 Config/update guidance
SILENT_REPLY_TOKEN{{INLINE_REPLY_MARKER}} auto-reply/tokens.ts ~19 Magic token
SYSTEM_PROMPT_CACHE_BOUNDARY system-prompt.ts (constant) ~60 Cache seam marker
## Model Aliases — alias table from config system-prompt.ts:806-815 Variable Dynamic from config
## Workspace + path guidance system-prompt.ts:819-822 Variable Dynamic from workspaceDir
## Sandbox — sandbox config details system-prompt.ts:825-886 Variable Dynamic from sandboxInfo
## Runtime — runtime info line system-prompt.ts:997-1001, 1006-1048 Variable Dynamic: agentId, os, model, channel
## Reactions — emoji guidance system-prompt.ts:912-934 Variable Conditional on reactionGuidance
## Reasoning Format system-prompt.ts:935-937 Variable Conditional on reasoningHint
## Silent Replies system-prompt.ts:955-970 ~200 Conditional on promptMode
Memory section content context.ts:buildMemorySection() Variable Function output
Skills section content system-prompt.ts:buildSkillsSection() Variable Function output
Docs / Project Context content system-prompt.ts:buildDocsSection() Variable Function output
Bootstrap prompt files (.pi/prompts/cl.md) bootstrap-prompt.ts 2,551 + 964 File content

4. Agent Loop — Turn-by-Turn Execution

The core agent loop lives in run/attempt.ts (3,290 lines). Each user message triggers one run attempt which may involve multiple turns (tool call cycles) within a single LLM API call.

4.1 Run Attempt Lifecycle — 6 Phases

PHASE 1: SETUP (attempt.ts lines ~200–900) ① Bootstrap routing: buildBootstrapPromptWarning · prependBootstrapPromptWarning · userPromptPrefixText ② System prompt assembly: buildEmbeddedSystemPrompt() → transformProviderSystemPrompt() ③ Session lock acquire · repairSessionFileIfNeeded · prewarmSessionFile · SessionManager.open() PHASE 2: TOOL PREPARATION (attempt.ts lines ~900–1400) ① Resolve effective tools: normalizeAgentRuntimeTools() · toClientToolDefinitions() · collectRegisteredToolNames() ② MCP tools: getOrCreateSessionMcpRuntime() · materializeBundleMcpToolsForRun() ③ Stream wrappers chain: idle-timeout · diagnostic-model-call · sanitize-tool-calls · repair-arguments · payload-logger PHASE 3: CONTEXT ASSEMBLY (attempt.ts lines ~1400–2000) ① sanitizeSessionHistory() · validateReplayTurns() · filterHeartbeatPairs() · limitHistoryTurns() ② contextEngine.assemble() — assembles message list with context engine additions ③ prependSystemPromptAddition() — context engine may add system prompt prefix PHASE 4: PRE-PROMPT HOOKS & TRANSFORMATION (attempt.ts lines ~2200–2600) ① resolvePromptBuildHookResult() — runs before_prompt_build hooks (prependContext, appendContext, systemPrompt override) ② Orphaned user message repair: mergeOrphanedTrailingUserPrompt() ③ Google Prompt Cache: prepareGooglePromptCacheStreamFn() · detectAndLoadPromptImages() PHASE 5: LLM API CALL — THE TURN LOOP (attempt.ts lines ~2600–3100) ① activeSession.agent.streamFn(model, context, options) → streams back tool_calls, text, thinking blocks ② Each tool_call → execute in sequence or parallel → collect tool_result → append to messages ③ Loop continues until stop_reason ≠ 'tool_use' (i.e., final answer, msg_edit, etc.) PHASE 6: PAYLOAD BUILDING & CLEANUP (attempt.ts lines ~3100–3290) ① buildEmbeddedRunPayloads() — extracts assistant texts, tool metas, errors → reply payloads

4.2 The Turn Loop — Inside Phase 5

The turn loop is the heart of the agent. Each iteration is one LLM API call that may produce multiple tool calls. The loop continues until the model produces a non-tool-use stop reason.

Turn_Loop:
  while (true):
    # Build context for this turn
    messages = activeSession.agent.state.messages  # includes all prior turns

    # Call LLM — single API request
    response = streamFn(model, messages, options)

    # Parse response for stop_reason
    stop_reason = response.stop_reason

    if stop_reason == "tool_use":
        # Model emitted one or more tool_call blocks
        tool_calls = response.content.blocks.filter(type="tool_use")

        # Execute tool calls
        for each tool_call in tool_calls (sequentially):
            tool_result = execute_tool(tool_call.name, tool_call.input)

            # Append tool_use + tool_result to transcript
            messages.append(tool_use_message(tool_call))
            messages.append(tool_result_message(tool_result))

        # Continue loop — next LLM call gets tool results

    elif stop_reason == "end_turn":
        # Model produced final answer text
        final_text = extract_visible_text(response)
        break

    elif stop_reason == "msg_edit":
        # Model edited a prior message
        apply_message_edit(response)
        break

    else:
        # Other stop reasons: max_tokens, stop_sequence, etc.
        break

  return final_text + payloads

The actual implementation in pi-coding-agent calls streamFn once per turn, which internally handles all tool_calls in the response stream. The transcript is mutated after each tool execution, and the next streamFn call picks up from the updated message list.

5. LLM API Calling & Stream Wrapping

5.1 Stream Function Resolution Chain

streamFn (the LLM streaming function) goes through a multi-stage resolution and wrapping pipeline:

Stage 1: Resolution (stream-resolution.ts)

resolveEmbeddedAgentStreamFn(params):
  # Priority 1: Provider-owned stream function
  if params.providerStreamFn:
    return providerStreamFn  # wrapped with auth injection

  # Priority 2: OpenAI WebSocket transport
  if shouldUseWebSocketTransport:
    if wsApiKey:
      return createOpenAIWebSocketStreamFn(...)
    return currentStreamFn  # fallback

  # Priority 3: Anthropic Vertex
  if model.provider == "anthropic-vertex":
    return createAnthropicVertexStreamFn(...)

  # Priority 4: Boundary-aware streaming
  if currentStreamFn == undefined || streamSimple:
    boundaryAware = createBoundaryAwareStreamFnForModel(model)
    if boundaryAware: return boundaryAware

  # Default: use session's streamFn
  return currentStreamFn

Stage 2: Wrapper Chain (attempt.ts lines 1777–1866)

1. innerStreamFn = activeSession.agent.streamFn

2. yield-abort wrapper
   → catches sessions_yield abort signal
   → returns createYieldAbortedResponse()

3. sanitize-malformed-tool-calls wrapper
   → fixes whitespace around tool names

4. trim-tool-call-names wrapper
   → normalizes tool names against allowlist

5. repair-malformed-tool-call-arguments wrapper
   → provider-specific argument repair (if needed)

6. decode-xai-tool-call-arguments wrapper
   → HTML entity decoding for xAI

7. anthropic-payload-logger wrapper
   → logs full request/response payloads

8. handle-sensitive-stop-reason wrapper
   → recovers from "sensitive" stop reason

9. idle-timeout wrapper (streamWithIdleTimeout)
   → aborts after idleTimeoutMs of no response

10. diagnostic-model-call-events wrapper
    → emits TrustedDiagnosticEvents for model calls

5.2 Transcript Policy Sanitizers (Per-Request Wrappers)

These wrappers are applied inside the turn loop — wrapping streamFn so every outbound LLM API request passes through sanitization:

WrapperTrigger ConditionWhat It Does
dropReasoningFromHistory transcriptPolicy.dropReasoningFromHistory Removes reasoning blocks from history messages before sending
dropThinkingBlocks transcriptPolicy.dropThinkingBlocks Strips thinking blocks from replayed messages (Anthropic restriction)
sanitizeReplayToolCallIds transcriptPolicy.sanitizeToolCallIds Normalizes tool call IDs to provider-acceptable formats (e.g., Mistral requires [a-zA-Z0-9]{9})
downgradeOpenAIReasoningBlocks model.api === "openai-responses" Strips reasoning blocks that OpenAI Responses API doesn't support in continuations
downgradeOpenAIFunctionCallReasoningPairs model.api === "openai-responses" Separates function-call + reasoning block pairs for OpenAI Responses API

5.3 Model Selection Resolution

Model resolution follows a priority chain in model.ts:

resolveEmbeddedAgentModel(params):
  1. EXPLICIT: params.model — passed directly (highest priority)
  2. CONFIG: params.config → model selection config
  3. RUNTIME: environment variable OPENCLAW_MODEL
  4. DEFAULT: provider-specific default (e.g., claude-3-5-sonnet)

  Then resolve API format:
  - "openai" / "openai-compatible" → OpenAI format
  - "anthropic" → Anthropic format
  - "openai-responses" → OpenAI Responses API
  - "anthropic-vertex" → Anthropic Vertex AI
  - "azure-openai" → Azure OpenAI
  - "openai-codex-responses" → Codex Responses API

The PiModelRegistry in model.ts handles provider plugin registration, inline provider config, and auth storage discovery (reading from ~/.config/openclaw/auth.json or environment variables).

6. Parallelism: Tool Execution & Session Architecture

6.1 Tool Execution — Sequential by Default, Bundle MCP

The key design decision: tool calls within a single LLM turn are executed sequentially. The model emits multiple tool_use blocks in one response, but they are processed one at a time in order.

Why Sequential?

Where Parallelism DOES Exist:

6.2 Session Compaction — The Background Parallel Operation

Session compaction is the primary source of genuine parallelism in the agent loop. It runs in parallel with the main loop via activeSession.isCompacting:

Compaction Flow:
  1. After each turn, check if compaction is needed:
     - context-window-guard.js evaluates: tokens_used / max_tokens > threshold

  2. If needed, trigger async compaction:
     activeSession.startCompaction()

  3. Compaction is a Pi Agent operation:
     - Reads full session transcript
     - Summarizes older tool results and intermediate messages
     - Produces a condensed transcript that fits in context window

  4. The main loop continues accepting new prompts DURING compaction:
     - If user sends new message while compacting:
       → abortCompaction() is called
       → new run attempt starts immediately
       → compaction is retried after new turn completes

  5. Compaction safety timeout:
     - compaction-timeout.ts: max compaction duration
     - If exceeded: abortCompaction() + log warning

  6. Preemptive compaction:
     - Before each prompt, check if context is near full
     - If route === "truncate_tool_results_only":
       → truncateOversizedToolResultsInSessionManager()
       → recover without full compaction

6.3 Harness Selection — Plugin vs PI (NOT Parallel)

Harness selection is a single synchronous decision at run start. It determines which agent backend runs the session:

selectAgentHarness(params):
  # Step 1: Check for pinned harness (session-specific override)
  pinnedPolicy = resolvePinnedAgentHarnessPolicy(agentHarnessId)
  if pinnedPolicy: return resolveToHarness(pinnedPolicy)

  # Step 2: Resolve runtime policy from config/env
  policy = resolveAgentHarnessPolicy(params)
  #   Policy.runtime: "pi" | "auto" | "plugin-id"

  # Step 3a: If runtime == "pi" → use built-in PI harness
  if runtime == "pi":
    return piHarness  # @mariozechner/pi-coding-agent

  # Step 3b: If runtime is a plugin ID → use that plugin harness
  if runtime is registered plugin harness:
    return pluginHarness
  else:
    if policy.fallback == "none":
      throw Error("harness not registered + no fallback")
    log.warn("falling back to PI")
    return piHarness

  # Step 3c: If runtime == "auto" → auto-select based on provider support
  candidates = pluginHarnesses.map(h => ({
    harness,
    support: h.supports({ provider, modelId, runtime })
  }))
  supported = candidates.filter(supported).sorted(byPriority()
  if supported[0]:
    return supported[0].harness  # highest priority
  else:
    if fallback == "none": throw
    return piHarness  # PI is always available as fallback

# FAILOVER: pi-embedded-runner/compact.ts
maybeCompactAgentHarnessSession(params):
  harness = selectAgentHarness(params)
  if harness.compact:
    return harness.compact(params)  # calls PI or plugin compaction
  if harness.id != "pi":
    return { ok: false, reason: "no compact support" }
  return undefined  # PI handles compaction internally

7. Complete Flow Diagram — Prompt → LLM → Response

OPENCLAW AGENT LOOP — COMPLETE FLOW User Message (Telegram/Discord/...) ✉ bootstrap-prompt.ts: resolveBootstrapContextForRun() buildBootstrapPromptWarning() · userPromptPrefixText system-prompt.ts: buildEmbeddedSystemPrompt() → transformProviderSystemPrompt() 20 sections assembled: identity · tooling · safety · skills · memory · docs · workspace · sandbox · runtime · ... Tool Resolution + Session Creation + Stream Wrapper Chain normalizeTools() · MCP bundle · 10-layer streamFn wrapper chain · SessionManager.open() Context Assembly: sanitizeHistory · contextEngine.assemble() · prependSystemPromptAddition Pre-Prompt Hooks: resolvePromptBuildHookResult() · Google Prompt Cache ⚡ TURN LOOP — activeSession.agent.streamFn() ⚡ ① Build Messages activeSession.messages + new user prompt ② LLM API Call streamFn(model, context) → streams tokens back ③ Parse Response stop_reason? tool_use / end_turn ④ Execute Tool Calls Sequential: tool[name, input] → tool_result → append to messages tool_use? YES → loop stop_reason ≠ tool_use → EXIT LOOP ⑤ Parallel Compaction Background: isCompacting context-window-guard.js buildEmbeddedRunPayloads() — Extract Text + Errors → Reply Payloads extractAssistantVisibleText() · formatToolAggregate() · buildToolErrorWarning() Channel Delivery: payloads → Telegram / Discord / Webhook / Cron subscribeEmbeddedPiSession() · pi-embedded-subscribe.ts ✓ Run Complete — Awaiting Next Message

8. Key Insights & Design Patterns

🏗️ Structural Insights

  • Wrapper Chain Pattern: The stream function is wrapped 10+ times, each wrapper adds one concern. This is functional composition — each wrapper is a standalone middleware that can be independently tested/traced.
  • Overridable Prompt Sections: Every major system prompt section can be overridden by a provider plugin — this is a clean dependency injection pattern at the string level.
  • Two-tier Cache Boundary: SYSTEM_PROMPT_CACHE_BOUNDARY splits stable content (above) from volatile group/dynamic additions (below). Anthropic-family transports reuse the stable portion across API calls.
  • Bootstrap Routing: Bootstrap prompt files (.pi/prompts/cl.md) are injected conditionally via resolveBootstrapContextForRun() — first-turn vs subsequent-turn behavior.

🔄 Loop Insights

  • Single-Turn LLM Call: Each loop iteration is exactly ONE streamFn call. Tool calls within that response are extracted and executed, results appended, then another streamFn call is made. The loop is at the transcript level, not the HTTP level.
  • Sequential Tool Execution: Tool calls are never executed in parallel within a turn. Order is preserved because tool results may depend on prior tool outputs.
  • Compaction as Co-routine: Session compaction runs as a background async operation. The main loop continues accepting work. If new work arrives, compaction is aborted and retried post-turn.
  • Harness is Chosen Once: The plugin harness vs PI harness decision is made at run start, not per-turn. All turns within a run use the same harness.

🔐 Safety & Sanitization

  • Per-Request Transcript Sanitizers: Unlike static sanitization, these are stream wrappers that re-sanitize history on every outbound LLM request. This handles the case where a tool result introduces invalid content that needs fixing on the next call.
  • Tool Allowlist at Session Creation: The allowlist is fixed at session creation. Custom tools (client-hosted) are checked for name conflicts at admission time.
  • Yield-Aborter: The sessions_yield abort signal is intercepted inside the stream wrapper — if the session yields while the LLM is streaming, a synthetic yield_aborted response is returned instead of an error.

📊 Observability

  • Diagnostic Model Call Events: Every outbound LLM API call emits a TrustedDiagnosticEvent with model, provider, transport, runId, sessionId — traceable across the system.
  • Context Cache Observability: beginPromptCacheObservation() tracks cache state changes per turn — enabling measurement of cache hit/miss rates across the session.
  • Trajectory Recording: createTrajectoryRuntimeRecorder() captures tool definitions, context assembly, and prompt sizes for ML training data pipelines.

Generated by Hermes · ClawdyHuang Research · Sovereign AI Analysis Framework

Source: github.com/openclaw/openclaw · Analysis Date: April 2026

Key files analyzed: system-prompt.ts (1,048L) · context.ts (21KB) · compact.ts (1,287L) · model.ts (1,121L) · run/attempt.ts (3,290L) · stream-resolution.ts (132L)