CodeDocs Vault

Design Patterns & Decisions

Patterns

1. Two-Tier Adapter (Channel Dock / Channel Plugin)

Where: src/channels/dock.ts, src/channels/plugins/types.plugin.ts

Pattern: Every channel has two representations:

Why: The gateway needs to reason about all channels (for routing, config UI) without loading every SDK. Docks are importable anywhere without side effects. Plugins are loaded lazily when a channel is activated.

Clever detail: buildDockFromPlugin() auto-generates a dock from a plugin's metadata, so extension channels only define the plugin and get the dock for free.

2. Factory-Based Tool Registration

Where: src/plugins/registry.ts, src/agents/pi-tools.ts

Pattern: Tools are registered as factories (functions that return tool instances), not as static objects.

api.registerTool(
  (ctx) => ctx.config?.channels?.discord?.enabled
    ? new DiscordActionTool(ctx.config)
    : null,  // Skip if Discord not configured
  { name: "discord_actions" }
);

Why: Tools need runtime context (workspace path, session key, config, channel capabilities). Factories defer instantiation until the context is available. Optional tools can return null to gracefully exclude themselves.

3. Event Subscription (Observer)

Where: src/agents/pi-embedded-subscribe.ts, src/agents/pi-embedded-subscribe.handlers.ts

Pattern: The Pi SDK emits events (message_start, tool_execution_start, etc.) and OpenClaw subscribes with a unified event handler that dispatches to specialized handlers.

session.subscribe(createEmbeddedPiSessionEventHandler(ctx));

Why: The agentic loop runs inside the SDK. OpenClaw can't (and shouldn't) modify it. Events decouple the loop from the UI/channel output layer. Different channels can react to the same events differently (e.g., streaming token-by-token for web, batching for Telegram).

4. Two-Level Hook System

Where: src/hooks/internal-hooks.ts (internal), src/plugins/hooks.ts (plugin)

Pattern: Two parallel hook systems:

Internal Hooks Plugin Hooks
Registration registerInternalHook(key, handler) api.on(hookName, handler, { priority })
Dispatch triggerInternalHook(event) hookRunner.runModifyingHook(name, event, ctx, merger)
Execution Parallel, fire-and-forget Sequential by priority, result merging
Use case Core lifecycle events Extensible behavior modification

Why: Core code needs simple, fast event dispatch (command lifecycle, etc.). Plugins need composable, priority-ordered hooks that can modify behavior (e.g., several plugins each prepending to the system prompt, merged in order).

Modifying hooks are notable: each handler returns partial data, and a merge function accumulates results. This lets multiple plugins contribute to the system prompt without conflicts:

hookRunner.runModifyingHook(
  "beforePromptBuild",
  event,
  ctx,
  mergeBeforePromptBuild  // Accumulates systemPrompt + prependContext
);

5. Registry + requireActive() Guard

Where: src/plugins/registry.ts

Pattern: The plugin registry is set globally via setActivePluginRegistry() and accessed via requireActivePluginRegistry(), which throws if called before initialization.

Why: Prevents code from accidentally accessing plugins before they're loaded. Makes the initialization order explicit and testable (tests can set/unset the registry). Avoids implicit global state — the "require" pattern makes the dependency visible.

6. Multi-Tier Routing with Fallback

Where: src/routing/resolve-route.ts

Pattern: Route resolution walks a priority chain:

  1. Direct peer → 2. Thread parent → 3. Guild+roles → 4. Guild → 5. Team → 6. Account → 7. Channel → 8. Default

Why: Different channels have different granularity. Discord has guilds, roles, and threads. WhatsApp has groups and direct messages. A single routing strategy can't handle all of them. The fallback chain lets admins configure bindings at any level and routes will "cascade" to the most specific match.

7. Session Write Locking

Where: src/agents/session-write-lock.ts

Pattern: Before modifying session state, acquireSessionWriteLock() must be called. The lock is file-based (proper-lockfile) with configurable max hold time.

Why: Multiple channels might trigger the same agent session simultaneously (e.g., user sends two WhatsApp messages quickly). The lock prevents concurrent writes from corrupting session state. The max hold time prevents deadlocks from crashed processes.

8. Modular Prompt Construction

Where: src/agents/system-prompt.ts

Pattern: The system prompt is built from independent sections:

const sections = [
  buildIdentityLine(...),
  buildSkillsSection(...),
  buildMemorySection(...),
  buildUserIdentitySection(...),
  buildTimeSection(...),
  buildReplyTagsSection(...),
  buildMessagingSection(...),
  buildVoiceSection(...),
  buildWorkspaceSection(...),
  buildRuntimeSection(...),
];

Each section function returns string[] (or empty array to exclude itself). Sections decide their own inclusion based on mode (full / minimal / none), available tools, and config.

Why: Different contexts need different prompts. Subagents get minimal prompts. CLI sessions skip messaging sections. The section pattern makes additions/removals safe — each section is self-contained.

9. Streaming with Coalescing

Where: Channel dock configurations in src/channels/dock.ts

Pattern: For channels with rate limits (Telegram, IRC), streaming output is coalesced:

streaming: {
  blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }
}

Tokens accumulate until either minChars characters are buffered or idleMs milliseconds pass without new tokens, then the batch is sent as one message.

Why: Token-by-token streaming works for web UIs but would flood messaging platforms with hundreds of tiny messages. Coalescing balances responsiveness with API rate limits.

10. Config Hot-Reload

Where: src/gateway/config-reload.ts, src/gateway/server-reload-handlers.ts

Pattern: File watcher detects config changes → triggers selective reload:

In-flight requests are not interrupted. New requests use the new config.

Why: The daemon runs continuously. Requiring a restart for config changes is unacceptable for a multi-channel assistant that might be mid-conversation.

Notable Design Tradeoffs

SDK Encapsulation vs. Control

The agentic loop lives inside the Pi agent SDK. OpenClaw can't directly control iteration, tool dispatch ordering, or retry logic within the loop. The tradeoff:

Lightweight Docks vs. Plugin Duplication

Channel metadata exists in two places (dock and plugin). This is intentional duplication:

File-Based Sessions vs. Database

Sessions are persisted as JSON files with file-based locking:

Dynamic Plugin Loading vs. Static Bundling

Plugins are discovered and dynamically imported at runtime:

What's Clever

Tool Definitions as LLM Prompts

Every tool's description field is carefully written as a prompt to the LLM. These descriptions are not documentation for developers — they're instructions for the AI on when and how to use the tool. This is a form of "prompt engineering at the tool level."

Session Transcript Repair

src/agents/session-transcript-repair.ts and src/agents/session-file-repair.ts handle corrupted session files — detecting and fixing malformed JSON, orphaned tool_use blocks without matching tool_result entries, and other edge cases. This robustness matters because sessions persist across process crashes.

Steer During Execution

session.steer(text) lets users inject messages during an active agentic loop. The SDK includes the injected message in the next LLM call, allowing mid-execution course correction. This is particularly useful for long-running agent tasks.

Model Failover Chain

src/agents/model-fallback.ts implements automatic failover between models. If the primary model fails (rate limit, auth error, billing), the system tries the next model in the chain. This is transparent to the user and the agent's tool state is preserved.

Bootstrap File Convention

The agent looks for OPENCLAW.md (or similar bootstrap files) in the workspace root. This file is included in the system prompt, letting projects customize agent behavior per-repository without any configuration. It's a convention-over-configuration pattern borrowed from tools like .editorconfig.