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.
- Part 1: Gateway — WebSocket to Agent Dispatch
- Part 2: Routing, Prompts & the Agent State Machine
- Part 3 (this post): Tools, Memory & the Hook Pipeline
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
Two-Pass Symlink Detection
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 fromsource: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 socketShell 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:
- Protocol whitelist — only HTTP/HTTPS
- IPv4 blocking —
10.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 - IPv6 blocking —
::,::1,fe80::/10,fc00::/7 - Embedded IPv4 detection — catches IPv4-mapped IPv6, NAT64, 6to4, Teredo, ISATAP tunneling
- Hostname denylist —
localhost,metadata.google.internal,.local,.internal - DNS pinning —
createPinnedLookup()resolves once and pins, preventing DNS rebinding - Redirect validation — max 3 redirects, loop detection, each target re-validated
Limits: 50KB default response, 2MB max, 10MB absolute ceiling. 30-second timeout.
Memory: Hybrid Vector + FTS Search
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 functionalAll 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:
- Union merge — combine vector and keyword results by ID
- Weighted scoring —
vectorWeight × vectorScore + textWeight × textScore - Temporal decay — exponential decay with configurable half-life (default 30 days)
- 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_selectedSimilarity 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, contentchunks_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
-
The double loop is elegant. Inner loop = tool execution + steering. Outer loop = follow-ups. Clean separation of interrupt vs. continuation semantics.
-
Steering between tool calls is the killer feature. Users can redirect the agent mid-execution. Remaining tools are skipped, new instructions injected.
-
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.
-
Memory degrades gracefully. Vector → FTS → nothing. The system never crashes on embedding failure.
-
SSRF protection is thorough. DNS pinning prevents rebinding. Embedded IPv4 detection catches tunneling tricks most implementations miss.
-
Two-pass symlink detection is rare in the wild. Most sandboxes check strings only. The
realpathpass 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.