Contents

OpenClaw Core Flow Part 3: Tools, Memory & the Hook Pipeline

Part 1 traced the gateway. Part 2 covered routing and the agent loop. Now we complete the picture: how tools execute in Docker sandboxes, how memory retrieval works, and the exact order of all 13 plugin hooks in the message path.


Tool Policy: Who Gets What

File: src/agents/tool-policy.ts

Tools are controlled through layered policies with group expansion:

const TOOL_GROUPS = {
  "group:memory": ["memory_search", "memory_get"],
  "group:web":    ["web_search", "web_fetch"],
  "group:fs":     ["read", "write", "edit", "apply_patch"],
  "group:runtime": ["exec", "process"],
};

Four presets control access:

Profile Access
minimal session_status only
coding filesystem, runtime, sessions, memory, images
messaging messaging + session tools
full unrestricted

Policies compile to glob matchers with deny-first semantics. The resolution hierarchy: agent-specific → global → agent-level → global tool policies. Sub-agents get additional restrictions — gateway, cron, memory_search, and sessions_send are always denied.


Sandbox: Docker Tool Execution

File: src/agents/sandbox/

Container Creation

buildSandboxCreateArgs() constructs Docker containers with hardened defaults:

  • --security-opt=no-new-privileges
  • Seccomp profiles for syscall filtering
  • AppArmor for mandatory access control
  • All Linux capabilities dropped
  • Read-only root filesystem
  • Memory + CPU + PID limits

File: src/agents/sandbox/validate-sandbox-security.ts

The security validation catches both obvious and clever escape attempts:

Pass 1 — String validation:

  • parseBindSourcePath() extracts host path from source:target[:mode]
  • normalizeHostPath() resolves ., .., collapses //
  • getBlockedBindReason() checks for root mount, blocked paths, relative paths

Pass 2 — Symlink resolution:

  • tryRealpathAbsolute() resolves to real filesystem location
  • Re-validates the resolved path against blocked paths
  • Catches: /innocent/path → symlink → /etc/shadow

Blocked host paths (never exposed):

/etc  /proc  /sys  /dev  /root  /boot  /run  /var/run  Docker socket

Shell Execution: Three Hosts

The exec tool routes commands to one of three execution hosts:

Host When Security
Sandbox (default) Containerized execution Restricted permissions
Gateway Local machine Allowlist + two-stage approval
Node Remote device Gateway proxy invocation

The two-stage approval mechanism: first analyzes the command against shell allowlist patterns, then requests user approval with “allow-once” or “allow-always” options. Approval IDs expire.


SSRF Protection: Web Fetch

File: src/infra/net/ssrf.ts

fetchWithSsrfGuard() implements multi-layered defense:

  1. Protocol whitelist — only HTTP/HTTPS
  2. IPv4 blocking10.0.0.0/8, 127.x.x.x, 169.254.x.x, 172.16-31.x.x, 192.168.x.x, 100.64-127.x.x
  3. IPv6 blocking::, ::1, fe80::/10, fc00::/7
  4. Embedded IPv4 detection — catches IPv4-mapped IPv6, NAT64, 6to4, Teredo, ISATAP tunneling
  5. Hostname denylistlocalhost, metadata.google.internal, .local, .internal
  6. DNS pinningcreatePinnedLookup() resolves once and pins, preventing DNS rebinding
  7. Redirect validation — max 3 redirects, loop detection, each target re-validated

Limits: 50KB default response, 2MB max, 10MB absolute ceiling. 30-second timeout.


Embedding Provider Chain

File: src/memory/embeddings.ts

Auto-mode resolution:

1. Local (GemmaEmbed 300M via node-llama-cpp) → free, private
2. OpenAI → remote API
3. Gemini → remote API
4. Voyage → remote API
5. FTS-only degradation → no vectors, still functional

All vectors are L2-normalized:

function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
  const sanitized = vec.map(v => Number.isFinite(v) ? v : 0);
  const magnitude = Math.sqrt(sanitized.reduce((sum, v) => sum + v * v, 0));
  if (magnitude < 1e-10) return sanitized;
  return sanitized.map(v => v / magnitude);
}

Hybrid Search Merge

File: src/memory/hybrid.ts

async function mergeHybridResults(params: {
  vector: HybridVectorResult[];
  keyword: HybridKeywordResult[];
  vectorWeight: number;
  textWeight: number;
  mmr?: Partial<MMRConfig>;
  temporalDecay?: Partial<TemporalDecayConfig>;
})

The algorithm:

  1. Union merge — combine vector and keyword results by ID
  2. Weighted scoringvectorWeight × vectorScore + textWeight × textScore
  3. Temporal decay — exponential decay with configurable half-life (default 30 days)
  4. MMR re-ranking — Maximal Marginal Relevance for result diversity

BM25 Normalization

Keyword search uses BM25 rankings, normalized to 0-1:

function bm25RankToScore(rank: number): number {
  return 1 / (1 + Math.max(0, rank));
}

MMR: Maximal Marginal Relevance

File: src/memory/mmr.ts

Carbonell & Goldstein (1998) implementation:

MMR = λ × relevance − (1 − λ) × max_similarity_to_selected

Similarity uses Jaccard on lowercase token sets. Default λ = 0.7 (relevance-weighted). The algorithm iteratively selects the item with highest MMR from remaining candidates, preventing redundant snippets.

Temporal Decay

File: src/memory/temporal-decay.ts

function calculateTemporalDecayMultiplier({ ageInDays, halfLifeDays }) {
  const lambda = Math.LN2 / halfLifeDays;
  return Math.exp(-lambda * Math.max(0, ageInDays));
}

Dated memory files (memory/2026-02-15.md) get age-based decay. “Evergreen” files (non-dated) keep full score.

Three-Table SQLite Design

  • chunks — individual chunks with path, source, hash, content
  • chunks_vec — virtual table for vector embeddings (sqlite-vec)
  • chunks_fts — full-text search index

Differential indexing skips unchanged files (hash comparison). Atomic swaps via temp database + file rename for safe reindexing.


The Complete Hook Pipeline

File: src/plugins/hooks.ts

Three execution patterns:

Pattern When Behavior
Void observation Parallel, fire-and-forget
Modifying transformation Sequential by priority, result merging
Synchronous hot path No async allowed

13-Step Hook Execution Order

Every message passes through these hooks in this exact sequence:

# Hook Pattern When
1 message_received void (parallel) After inbound normalization
2 before_model_resolve modifying (sequential) During model selection
3 before_prompt_build modifying (sequential) Before system prompt assembly
4 before_agent_start modifying (sequential) Before LLM call
5 llm_input modifying (sequential) Before each LLM API call
6 before_tool_call modifying (sequential) Before each tool execution
7 tool_result_persist sync After tool result, before persistence
8 after_tool_call modifying (sequential) After each tool execution
9 llm_output modifying (sequential) After each LLM response
10 agent_end void (parallel) After agent turn completes
11 message_sending modifying (sequential) Before outbound delivery
12 before_message_write sync Before transcript persistence
13 message_sent void (parallel) After successful delivery

Hooks 6-9 repeat for each tool call within a turn. The before_tool_call hook can modify tool arguments or block the call entirely. The message_sending hook can alter the outgoing message before it reaches the channel.

Hook Runner Implementation

function createHookRunner(hooks, options) {
  function getHooksForName(name) {
    return hooks
      .filter(h => h.name === name)
      .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); // High priority first
  }

  // Void: run all in parallel
  async function runVoidHooks(name, data) {
    await Promise.all(matched.map(h => h.handler(data)));
  }

  // Modifying: run sequentially, merge results
  async function runModifyingHooks(name, initial, merge) {
    let result = initial;
    for (const hook of matched) {
      const hookResult = await hook.handler(result);
      if (hookResult !== undefined) result = merge(result, hookResult);
    }
    return result;
  }

  // Sync: no async, warn if handler returns Promise
  function runSyncHooks(name, data) {
    for (const hook of matched) {
      const result = hook.handler(data);
      if (result instanceof Promise) logger?.warn?.("Async in sync hook");
    }
  }
}

End-to-End: WhatsApp Message → Response

Putting it all together for a WhatsApp DM:

WhatsApp webhook fires
  → Channel adapter builds MsgContext { Channel:"whatsapp", From: JID, Body: text }
  → finalizeInboundContext() normalizes fields
  → [Hook 1: message_received]

resolveAgentRoute({ channel:"whatsapp", peer:{ kind:"direct", id:JID }})
  → Tier 6 match (binding.account) → default agent
  → sessionKey: "agent:main:direct:5511999999999"

recordInboundSession() → persist metadata

dispatchReplyFromConfig()
  → [Hook 2: before_model_resolve] → model selected
  → [Hook 3: before_prompt_build] → system prompt assembled
  → runReplyAgent()
    → [Hook 4: before_agent_start]
    → createAgentSession() → Agent + AgentSession
    → session.prompt(text)
      → Agent._runLoop()
        → agentLoop() → DOUBLE LOOP:
          → [Hook 5: llm_input]
          → streamAssistantResponse() → LLM API call
          → [Hook 9: llm_output]
          → If tool calls:
            → [Hook 6: before_tool_call]
            → tool.execute() (in Docker sandbox)
            → [Hook 7: tool_result_persist] (sync)
            → [Hook 8: after_tool_call]
            → Check steering → loop or exit
          → agent_end event
    → [Hook 10: agent_end]

Reply delivery:
  → [Hook 11: message_sending] → may modify outgoing
  → [Hook 12: before_message_write] (sync)
  → WhatsApp outbound adapter → text chunked to 4000 chars
  → [Hook 13: message_sent]

Gateway WebSocket:
  → agent events → 150ms throttle → broadcast (dropIfSlow)
  → final message → broadcast (guaranteed delivery)

Key Architectural Insights

  1. The double loop is elegant. Inner loop = tool execution + steering. Outer loop = follow-ups. Clean separation of interrupt vs. continuation semantics.

  2. Steering between tool calls is the killer feature. Users can redirect the agent mid-execution. Remaining tools are skipped, new instructions injected.

  3. 13 hook points give plugins total control. They can modify model selection, tool arguments, outgoing messages, and even block tool calls entirely — all without touching core code.

  4. Memory degrades gracefully. Vector → FTS → nothing. The system never crashes on embedding failure.

  5. SSRF protection is thorough. DNS pinning prevents rebinding. Embedded IPv4 detection catches tunneling tricks most implementations miss.

  6. Two-pass symlink detection is rare in the wild. Most sandboxes check strings only. The realpath pass catches the attacks that matter.


This completes our deep analysis of the OpenClaw core flow. For the architecture overview, see Part 1, Part 2, Part 3 of the original series.

Analysis based on OpenClaw v2026.2.18 and pi-mono v0.53.0. Sources: openclaw/openclaw, badlogic/pi-mono. Research from cryptocj.org.