Skip to content

Instantly share code, notes, and snippets.

@jalehman
Created March 18, 2026 23:53
Show Gist options
  • Select an option

  • Save jalehman/6457ca0dfc7fa8cd30acb64d6c96b2fb to your computer and use it in GitHub Desktop.

Select an option

Save jalehman/6457ca0dfc7fa8cd30acb64d6c96b2fb to your computer and use it in GitHub Desktop.
OpenClaw Plugin Architecture — How extensions go from TypeScript source to running code #pagedrop
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenClaw Plugin Architecture</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/black.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/monokai.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--r-background-color: #0a0a0f;
--r-main-font: 'Space Grotesk', system-ui, sans-serif;
--r-main-font-size: 32px;
--r-heading-font: 'Space Grotesk', system-ui, sans-serif;
--r-code-font: 'JetBrains Mono', 'Space Mono', monospace;
--r-main-color: #e2e0d8;
--r-heading-color: #f5f3eb;
--r-link-color: #f59e42;
--r-link-color-hover: #fbbf68;
--r-selection-background-color: #f59e4244;
--r-heading-text-transform: none;
--accent: #f59e42;
--accent2: #e05252;
--accent3: #42b8f5;
--accent4: #6dd672;
--dim: #6b6a64;
--surface: #16161e;
--surface2: #1e1e28;
}
.reveal {
font-weight: 400;
}
.reveal h1, .reveal h2, .reveal h3 {
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.15;
}
.reveal h1 { font-size: 2.4em; }
.reveal h2 { font-size: 1.6em; margin-bottom: 0.6em; }
.reveal h3 { font-size: 1.1em; color: var(--accent); margin-bottom: 0.4em; }
.reveal code {
font-family: var(--r-code-font);
font-size: 0.85em;
background: var(--surface);
padding: 0.1em 0.35em;
border-radius: 4px;
color: var(--accent3);
}
.reveal pre {
width: 100%;
box-shadow: none;
font-size: 0.52em;
}
.reveal pre code {
background: var(--surface);
border: 1px solid #2a2a36;
border-radius: 8px;
padding: 1em 1.2em;
line-height: 1.55;
max-height: 520px;
color: var(--r-main-color);
}
.reveal table {
margin: 0 auto;
border-collapse: collapse;
font-size: 0.72em;
}
.reveal table th {
background: var(--surface2);
color: var(--accent);
font-weight: 600;
padding: 0.5em 0.8em;
border-bottom: 2px solid var(--accent);
text-align: left;
}
.reveal table td {
padding: 0.4em 0.8em;
border-bottom: 1px solid #2a2a36;
text-align: left;
}
.reveal table tr:last-child td { border-bottom: none; }
.reveal ul, .reveal ol {
display: block;
text-align: left;
margin-left: 0;
font-size: 0.88em;
line-height: 1.7;
}
.reveal li { margin-bottom: 0.25em; }
.reveal .slides section {
text-align: left;
padding: 0 40px;
}
/* Title slide */
.title-slide {
text-align: center !important;
}
.title-slide h1 {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 50%, var(--accent3) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-size: 2.6em !important;
}
.title-slide .subtitle {
color: var(--dim);
font-size: 0.9em;
margin-top: 0.8em;
font-weight: 400;
}
/* Section divider slides */
.section-divider {
text-align: center !important;
}
.section-divider h2 {
font-size: 2em !important;
}
.section-divider .section-num {
display: inline-block;
color: var(--accent);
font-family: var(--r-code-font);
font-size: 0.6em;
border: 2px solid var(--accent);
border-radius: 50%;
width: 1.6em;
height: 1.6em;
line-height: 1.5em;
text-align: center;
margin-bottom: 0.4em;
}
/* Diagram styling */
.diagram {
background: var(--surface);
border: 1px solid #2a2a36;
border-radius: 8px;
padding: 1em 1.4em;
font-family: var(--r-code-font);
font-size: 0.48em;
line-height: 1.6;
white-space: pre;
overflow-x: auto;
color: var(--r-main-color);
}
/* Highlight spans */
.hl { color: var(--accent); font-weight: 600; }
.hl2 { color: var(--accent2); font-weight: 600; }
.hl3 { color: var(--accent3); font-weight: 600; }
.hl4 { color: var(--accent4); font-weight: 600; }
.dim { color: var(--dim); }
/* Badge/pill */
.pill {
display: inline-block;
background: var(--surface2);
border: 1px solid #2a2a36;
border-radius: 999px;
padding: 0.15em 0.65em;
font-size: 0.75em;
color: var(--accent);
font-family: var(--r-code-font);
margin: 0 0.15em;
}
/* Stat bars */
.stat-bar {
display: flex;
align-items: center;
gap: 0.6em;
margin: 0.35em 0;
font-size: 0.78em;
}
.stat-bar .bar {
height: 24px;
border-radius: 4px;
transition: width 0.8s ease;
}
.stat-bar .label {
min-width: 120px;
text-align: right;
font-family: var(--r-code-font);
font-size: 0.85em;
color: var(--dim);
}
.stat-bar .value {
font-family: var(--r-code-font);
font-size: 0.85em;
}
/* Two-column layout */
.columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5em;
align-items: start;
}
.columns > div { min-width: 0; }
/* Key-file badge */
.key-file {
font-family: var(--r-code-font);
font-size: 0.6em;
color: var(--dim);
margin-bottom: 0.8em;
}
/* Fragment custom: fade up with color */
.reveal .fragment.pop-in {
opacity: 0;
transform: translateY(12px);
transition: all 0.4s ease;
}
.reveal .fragment.pop-in.visible {
opacity: 1;
transform: translateY(0);
}
/* Progress bar color */
.reveal .progress span { background: var(--accent); }
/* Slide number */
.reveal .slide-number { color: var(--dim); font-family: var(--r-code-font); font-size: 14px; }
</style>
</head>
<body>
<div class="reveal">
<div class="slides">
<!-- ============================================================ -->
<!-- TITLE -->
<!-- ============================================================ -->
<section class="title-slide" data-transition="zoom">
<svg class="lobster" width="80" height="80" viewBox="0 0 100 100" style="margin-bottom:-0.2em;filter:drop-shadow(0 0 12px rgba(245,158,66,0.35));">
<g fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<!-- body -->
<ellipse cx="50" cy="52" rx="14" ry="18" fill="var(--accent)" opacity="0.15"/>
<ellipse cx="50" cy="52" rx="14" ry="18"/>
<!-- tail segments -->
<path d="M42 68 Q50 78 58 68" fill="var(--accent)" opacity="0.1"/>
<path d="M42 68 Q50 78 58 68"/>
<path d="M44 74 Q50 84 56 74"/>
<path d="M40 80 L50 90 L60 80" fill="var(--accent)" opacity="0.1"/>
<path d="M40 80 L50 90 L60 80"/>
<!-- head -->
<ellipse cx="50" cy="36" rx="10" ry="7" fill="var(--accent)" opacity="0.15"/>
<ellipse cx="50" cy="36" rx="10" ry="7"/>
<!-- eyes -->
<circle cx="45" cy="32" r="2.5" fill="var(--accent)"/>
<circle cx="55" cy="32" r="2.5" fill="var(--accent)"/>
<line x1="44" y1="28" x2="42" y2="23"/>
<line x1="56" y1="28" x2="58" y2="23"/>
<circle cx="42" cy="22" r="1.5" fill="var(--accent)"/>
<circle cx="58" cy="22" r="1.5" fill="var(--accent)"/>
<!-- antennae -->
<path d="M44 30 Q35 18 28 12" stroke-width="2"/>
<path d="M56 30 Q65 18 72 12" stroke-width="2"/>
<!-- left claw -->
<path d="M36 42 Q26 36 20 40" stroke-width="2.5"/>
<path d="M20 40 Q14 34 10 36" stroke-width="2.5"/>
<path d="M20 40 Q16 44 12 44" stroke-width="2.5"/>
<ellipse cx="11" cy="40" rx="5" ry="6" transform="rotate(-15 11 40)" fill="var(--accent2)" opacity="0.2" stroke="var(--accent2)" stroke-width="2"/>
<!-- right claw -->
<path d="M64 42 Q74 36 80 40" stroke-width="2.5"/>
<path d="M80 40 Q86 34 90 36" stroke-width="2.5"/>
<path d="M80 40 Q84 44 88 44" stroke-width="2.5"/>
<ellipse cx="89" cy="40" rx="5" ry="6" transform="rotate(15 89 40)" fill="var(--accent2)" opacity="0.2" stroke="var(--accent2)" stroke-width="2"/>
<!-- legs -->
<path d="M38 48 L28 54" stroke-width="1.8"/>
<path d="M37 54 L26 58" stroke-width="1.8"/>
<path d="M38 60 L28 66" stroke-width="1.8"/>
<path d="M62 48 L72 54" stroke-width="1.8"/>
<path d="M63 54 L74 58" stroke-width="1.8"/>
<path d="M62 60 L72 66" stroke-width="1.8"/>
</g>
</svg>
<h1>OpenClaw<br>Plugin Architecture</h1>
<p class="subtitle">How extensions go from TypeScript source to running code</p>
<p style="margin-top:1.5em;">
<span class="pill">tsdown</span>
<span class="pill">jiti</span>
<span class="pill">Plugin Registry</span>
<span class="pill">Hooks</span>
<span class="pill">Runtime Proxy</span>
</p>
</section>
<!-- ============================================================ -->
<!-- PIPELINE OVERVIEW -->
<!-- ============================================================ -->
<section data-transition="slide">
<h2>The Pipeline: Three Layers</h2>
<div class="diagram">┌───────────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────────┐
│ Extension │ │ tsdown │ │ Discovery │ │ Plugin │
│ Source (.ts) │───>│ Build │───>│ + Manifest │───>│ Registry │
│ │ │ (unified)│ │ Loading │ │ (in-memory) │
└───────────────┘ └──────────┘ └──────┬───────┘ └───────┬────────┘
│ │
v v
┌──────────┐ ┌────────────────┐
│ jiti │ │ Hooks, Tools, │
│ (loader) │──────>│ Channels, │
└──────────┘ │ Providers │
└────────────────┘</div>
<table style="margin-top:0.8em;">
<tr><th>Layer</th><th>Tool</th><th>Role</th></tr>
<tr><td><span class="hl">Build</span></td><td>tsdown</td><td>Compile all extensions + SDK into <code>dist/</code></td></tr>
<tr><td><span class="hl">Load</span></td><td>jiti</td><td>Import plugin modules at runtime (.js native or .ts transpiled)</td></tr>
<tr><td><span class="hl">Wire</span></td><td>Plugin Registry</td><td>Collect hooks, tools, channels, providers for the core</td></tr>
</table>
</section>
<!-- ============================================================ -->
<!-- SECTION 1: BUILD -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">1</div>
<h2>Build: tsdown</h2>
<p class="dim" style="font-size:0.7em;">Unified Entry Graph</p>
</section>
<section>
<h2>Single Build Graph</h2>
<p class="key-file">tsdown.config.ts</p>
<p>One root config compiles <strong>everything</strong> in a single invocation:</p>
<pre><code class="language-plaintext">tsdown entry graph
├── Core entries (index.ts, entry.ts, CLI, infra, channels)
├── Plugin-SDK subpaths (122 exports)
├── Bundled extension entries (extensions/*/package.json)
└── Bundled hooks (src/hooks/bundled/*/handler.ts)</code></pre>
<h3 style="margin-top:0.8em;">Why a single graph?</h3>
<ul>
<li>Runtime singletons (config, registry, hook runner) emitted <strong>once</strong></li>
<li>Separate builds → duplicate module instances → broken shared state</li>
<li><code>import "openclaw/plugin-sdk"</code> must resolve to the same graph</li>
</ul>
</section>
<section>
<h2>Extension Entry Declaration</h2>
<p>Extensions declare entries in <code>package.json</code>, not in a build config:</p>
<pre><code class="language-json">{
"name": "@openclaw/discord",
"openclaw": {
"extensions": ["./index.ts"],
"setupEntry": "./setup-entry.ts"
}
}</code></pre>
<ul>
<li><code class="hl">extensions[]</code> — runtime entry (full plugin with handlers)</li>
<li><code class="hl">setupEntry</code> — optional pre-listen setup (config UI, onboarding)</li>
</ul>
<p style="font-size:0.78em; color:var(--dim); margin-top:0.6em;">tsdown discovers these dynamically at build time via <code>listBundledPluginBuildEntries()</code></p>
</section>
<section>
<h2>Build Output: Three Shapes</h2>
<p style="font-size:0.82em;">Each extension → single <code>index.js</code> (tree-shaken, no code-splitting)</p>
<table>
<tr><th>Shape</th><th>JS files</th><th>Examples</th></tr>
<tr>
<td><span class="hl4">Provider / utility</span></td>
<td><code>index.js</code> only</td>
<td>anthropic (16 KB), openai (22 KB), voice-call (179 KB)</td>
</tr>
<tr>
<td><span class="hl3">Channel plugin</span></td>
<td><code>index.js</code> + <code>setup-entry.js</code></td>
<td>discord, telegram, slack, signal</td>
</tr>
<tr>
<td><span class="hl2">Optional cluster</span></td>
<td>0 JS files (metadata only)</td>
<td>matrix, msteams, nostr, memory-lancedb</td>
</tr>
</table>
<p style="font-size:0.72em; color:var(--dim); margin-top:0.6em;">
Optional clusters defined in <code>scripts/lib/optional-bundled-clusters.mjs</code><br>
Excluded from default build via <code>shouldBuildBundledCluster()</code><br>
Compile only when <code>OPENCLAW_INCLUDE_OPTIONAL_BUNDLED=1</code>
</p>
</section>
<section>
<h2>Disk Layout</h2>
<pre><code class="language-plaintext">dist/
├── plugin-sdk/
│ ├── root-alias.cjs ← CJS facade (Proxy)
│ ├── compat.js ← monolithic SDK bundle
│ ├── core.js ← scoped subpath
│ ├── discord.js ← channel-specific SDK
│ └── ... (122 subpaths)
└── extensions/
├── discord/ ← channel plugin (2 JS)
│ ├── index.js ← 5.7 KB
│ ├── setup-entry.js ← 5.7 KB
│ ├── node_modules/ ← symlink → source
│ ├── openclaw.plugin.json
│ └── package.json ← rewritten (.ts → .js)
├── voice-call/ ← utility (1 JS, 179 KB)
│ └── index.js
└── matrix/ ← optional cluster (0 JS)
├── openclaw.plugin.json
└── package.json</code></pre>
</section>
<section>
<h2>Post-Build Staging</h2>
<p class="key-file">scripts/runtime-postbuild.mjs</p>
<ol>
<li><strong>Copy <code>root-alias.cjs</code></strong> from <code>src/</code> → <code>dist/</code> <span class="dim">(plain CJS, no transpilation)</span></li>
<li><strong>Rewrite extension <code>package.json</code></strong>: flip <code>.ts</code> → <code>.js</code> refs</li>
<li><strong>Create <code>dist-runtime/</code></strong> overlay with re-export wrappers + symlinks</li>
</ol>
<pre><code class="language-plaintext">dist-runtime/extensions/discord/
├── index.js ← re-export → dist/extensions/discord/index.js
├── package.json
├── openclaw.plugin.json
└── node_modules/ ← symlink → extensions/discord/node_modules/</code></pre>
</section>
<!-- ============================================================ -->
<!-- SECTION 2: DISCOVERY -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">2</div>
<h2>Discovery</h2>
<p class="dim" style="font-size:0.7em;">Finding Plugins at Runtime</p>
</section>
<section>
<h2>Four Plugin Origins</h2>
<p class="key-file">src/plugins/discovery.ts</p>
<table>
<tr><th>Origin</th><th>Location</th><th>Priority</th></tr>
<tr><td><span class="hl">config</span></td><td><code>plugins.loadPaths[]</code> in user config</td><td>Highest</td></tr>
<tr><td><span class="hl">workspace</span></td><td><code>extensions/</code> in project root</td><td>↑</td></tr>
<tr><td><span class="hl">global</span></td><td><code>~/.openclaw/extensions/</code></td><td>↑</td></tr>
<tr><td><span class="hl">bundled</span></td><td><code>~/.openclaw/stock/extensions/</code></td><td>Lowest</td></tr>
</table>
<div style="margin-top:0.8em; font-size:0.82em;">
<h3>Plugin Manifest</h3>
<pre><code class="language-json">{
"id": "discord",
"channels": ["discord"],
"providers": [],
"configSchema": {}
}</code></pre>
<p class="dim" style="font-size:0.85em;">Deduplicates by plugin ID — higher-origin wins. 1-second cache.</p>
</div>
</section>
<section>
<h2>Path Safety</h2>
<p class="key-file">src/plugins/path-safety.ts</p>
<p>Before loading any plugin file, the system validates:</p>
<ul>
<li><span class="hl2">Symlink rejection</span> — configurable per origin</li>
<li><span class="hl2">World-writable directory</span> detection</li>
<li><span class="hl2">Ownership validation</span> (Unix)</li>
<li><span class="hl2">Escape detection</span> — realpath must stay inside plugin root</li>
</ul>
<p style="font-size:0.78em; color:var(--dim); margin-top:0.8em;">Prevents malicious plugin injection via crafted symlinks or path traversal</p>
</section>
<!-- ============================================================ -->
<!-- SECTION 3: DEPENDENCIES -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">3</div>
<h2>Dependency Handling</h2>
<p class="dim" style="font-size:0.7em;">Three strategies for plugin dependencies</p>
</section>
<section>
<h2>Dependency Strategies</h2>
<table>
<tr><th>Extension type</th><th>Dependencies</th><th>Resolution</th></tr>
<tr>
<td><span class="hl4">Bundled (no deps)</span></td>
<td>Core only, shared chunks</td>
<td><code>dist/*.js</code> relative imports</td>
</tr>
<tr>
<td><span class="hl3">Bundled (with deps)</span></td>
<td>Own + root <code>package.json</code></td>
<td>External bare-specifier → root <code>node_modules/</code></td>
</tr>
<tr>
<td><span class="hl2">Optional cluster</span></td>
<td>Own <code>package.json</code></td>
<td>Isolated <code>node_modules/</code> via <code>npm install --omit=dev</code></td>
</tr>
<tr>
<td><span class="hl">Plugin-SDK subpaths</span></td>
<td>Never bundled</td>
<td>jiti alias map → <code>dist/plugin-sdk/*.js</code></td>
</tr>
</table>
<div style="margin-top:0.8em;">
<h3>Shared chunks</h3>
<p style="font-size:0.78em;">tsdown splits shared code into <strong>~668 chunks</strong> in <code>dist/</code>. Each plugin index.js is thin — imports from chunks via relative paths.</p>
</div>
</section>
<section>
<h2>External Deps in Practice</h2>
<pre><code class="language-javascript">// Built dist/extensions/voice-call/index.js
// deps kept as external bare-specifier imports:
import { z } from "zod";
import WebSocket from "ws";
import { Type } from "@sinclair/typebox";</code></pre>
<ul>
<li>Deps declared in both extension + root <code>package.json</code></li>
<li>tsdown treats root-level deps as external — not bundled</li>
<li>At runtime: resolve from root <code>node_modules/</code></li>
<li>Only <code>@lancedb/lancedb</code> explicitly marked <code>neverBundle</code> <span class="dim">(native bindings)</span></li>
</ul>
</section>
<!-- ============================================================ -->
<!-- SECTION 4: JITI -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">4</div>
<h2>Loading: jiti</h2>
<p class="dim" style="font-size:0.7em;">The Module Bridge</p>
</section>
<section>
<h2>What is jiti?</h2>
<p class="key-file">src/plugins/loader.ts</p>
<p><a href="https://github.com/unjs/jiti" style="color:var(--accent3)">jiti</a> (Just-In-Time Import) — runtime module loader from the <a href="https://unjs.io" style="color:var(--accent3)">unjs</a> ecosystem.</p>
<div style="margin-top:0.6em;">
<h3>The problem it solves</h3>
<p style="font-size:0.82em;">Node.js cannot natively <code>import()</code> a <code>.ts</code> file. A plugin system needs a loader that:</p>
<ol style="font-size:0.82em;">
<li><span class="hl">Transpiles <code>.ts</code></span> on-the-fly <span class="dim">(dev mode)</span></li>
<li><span class="hl">Delegates to native ESM</span> for pre-built <code>.js</code> <span class="dim">(production)</span></li>
<li><span class="hl">Resolves custom import paths</span> like <code>openclaw/plugin-sdk/core</code></li>
<li><span class="hl">Handles CJS/ESM interop</span> since <code>root-alias.cjs</code> is CommonJS</li>
</ol>
</div>
</section>
<section>
<h2>jiti Configuration</h2>
<table>
<tr><th>Option</th><th>Value</th><th>Purpose</th></tr>
<tr><td><code>tryNative</code></td><td><code>true</code></td><td>Use Node's native ESM for <code>.js</code></td></tr>
<tr><td><code>interopDefault</code></td><td><code>true</code></td><td>Unwrap CJS <code>module.exports</code> as ESM default</td></tr>
<tr><td><code>alias</code></td><td><code>{...}</code></td><td>Map 122 plugin-SDK import paths</td></tr>
</table>
<pre style="margin-top:0.8em;"><code class="language-typescript">createJiti(modulePath, {
interopDefault: true,
tryNative: true,
extensions: [".ts", ".tsx", ".mts", ".cts",
".js", ".mjs", ".cjs", ".json"],
alias: {
"openclaw/plugin-sdk": "dist/plugin-sdk/root-alias.cjs",
"openclaw/plugin-sdk/core": "dist/plugin-sdk/core.js",
"openclaw/plugin-sdk/discord": "dist/plugin-sdk/discord.js",
// ... 122 scoped subpaths total
}
});</code></pre>
</section>
<section>
<h2>How jiti Loads a Plugin</h2>
<div class="diagram">Plugin entry: extensions/discord/index.ts (dev)
or: dist/extensions/discord/index.js (prod)
v
createJiti(modulePath, options)
├── File is .js / .mjs / .cjs ?
│ └── tryNative: true → <span class="hl4">Node native ESM</span> (fast, zero transpilation)
└── File is .ts / .tsx ?
└── <span class="hl">jiti transpiles via esbuild</span> → evaluates result</div>
<div style="margin-top:0.8em;">
<h3>Alias Resolution Strategy</h3>
<div class="diagram" style="font-size:0.52em;"><span class="hl4">Production</span> (running from dist/) → try dist/*.js first, fall back to src/*.ts
<span class="hl3">Development</span> (running from src/) → try src/*.ts first, fall back to dist/*.js</div>
</div>
</section>
<section>
<h2>The root-alias.cjs Proxy</h2>
<p class="key-file">src/plugin-sdk/root-alias.cjs</p>
<div class="diagram"><span class="dim">import { definePlugin } from "openclaw/plugin-sdk"</span>
v
root-alias.cjs (<span class="hl">Proxy</span>)
┌───────────────────────┐
│ <span class="hl4">Fast exports:</span> │──> emptyPluginConfigSchema
│ (no lazy load) │ resolveControlCommandGate
├───────────────────────┤
│ <span class="hl3">All other exports:</span> │──> Lazy-loads compat.js
│ (Proxy get trap) │ on first property access
└───────────────────────┘</div>
<p style="font-size:0.78em; margin-top:0.6em;">Frequently-used helpers available immediately. Full SDK (type builders, channel helpers) defers until first access.</p>
</section>
<section>
<h2>Why Not Alternatives?</h2>
<table>
<tr><th>Alternative</th><th>Problem</th></tr>
<tr><td>Node <code>--loader</code> hooks</td><td>Process-global, async-only — affects all imports</td></tr>
<tr><td><code>tsx</code></td><td>Spawns child process — jiti runs in-process, synchronous</td></tr>
<tr><td><code>esbuild</code> alone</td><td>Requires a build step — jiti wraps it on-demand</td></tr>
<tr><td>Custom resolver</td><td>jiti's alias map is simpler for 122 subpaths</td></tr>
</table>
</section>
<!-- ============================================================ -->
<!-- SECTION 5: REGISTRATION -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">5</div>
<h2>Registration</h2>
<p class="dim" style="font-size:0.7em;">Building the Plugin Registry</p>
</section>
<section>
<h2>The Load Sequence</h2>
<p class="key-file">src/plugins/registry.ts</p>
<div class="diagram">loadOpenClawPlugins(options)
├── 1. Check cache (<span class="hl3">LRU, 128 entries</span>)
├── 2. <span class="hl">discoverOpenClawPlugins()</span> → PluginCandidate[]
├── 3. <span class="hl">loadPluginManifestRegistry()</span> → manifests + metadata
├── 4. <span class="hl">createPluginRegistry()</span> → empty registry
├── 5. For each candidate (sorted by origin rank):
│ ├── Validate enable state (config allow/deny)
│ ├── Validate config schema
│ ├── Path safety check
│ ├── Load module via <span class="hl3">jiti</span>
│ ├── Create PluginApi wrapper
│ └── Call <span class="hl2">register(api)</span>
├── 6. <span class="hl">setActivePluginRegistry()</span> [global Symbol]
└── 7. Cache registry in LRU map</div>
</section>
<section>
<h2>What Plugins Register</h2>
<pre><code class="language-typescript">export function register(api: OpenClawPluginApi) {
api.registerChannel({ ... });
api.registerHook("before_agent_start", async (event) => {
return { systemContext: "Extra instructions..." };
});
api.registerTool((ctx) => ({
name: "discord-lookup",
description: "Look up a Discord user",
inputSchema: { ... },
handler: async (input) => { ... }
}));
api.registerCommand({ name: "ping", ... });
api.registerHttpRoute({ method: "POST", path: "/webhook", ... });
api.registerProvider({ ... });
api.registerService({ ... });
}</code></pre>
</section>
<section>
<h2>The PluginRegistry Shape</h2>
<pre><code class="language-typescript">type PluginRegistry = {
plugins: PluginRecord[];
tools: PluginToolRegistration[];
hooks: Map&lt;PluginHookName, HookEntry[]&gt;;
channels: PluginChannelRegistration[];
providers: ProviderPlugin[];
speechProviders: SpeechProviderPlugin[];
imageProviders: ImageGenerationProviderPlugin[];
mediaProviders: MediaUnderstandingProviderPlugin[];
webSearchProviders: WebSearchProviderPlugin[];
commands: OpenClawPluginCommandDefinition[];
services: OpenClawPluginService[];
httpRoutes: OpenClawPluginHttpRouteMatch[];
gatewayMethods: Record&lt;string, GatewayRequestHandler&gt;;
diagnostics: PluginDiagnostic[];
};</code></pre>
</section>
<!-- ============================================================ -->
<!-- SECTION 6: RUNTIME -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">6</div>
<h2>Runtime</h2>
<p class="dim" style="font-size:0.7em;">The PluginRuntime API</p>
</section>
<section>
<h2>Lazy PluginRuntime Proxy</h2>
<p class="key-file">src/plugins/runtime/index.ts</p>
<div class="columns">
<div>
<div class="diagram" style="font-size:0.44em;">PluginRuntime (<span class="hl">Proxy</span>)
├── version
├── config
├── agent
├── subagent
├── system
├── media
├── tts / stt
├── tools
├── channel
├── events
├── logging
├── state
└── modelAuth</div>
</div>
<div style="font-size:0.82em;">
<h3>Pay only for what you use</h3>
<p>Properties materialize <strong>only on first access</strong>.</p>
<p style="margin-top:0.5em;">If a plugin never touches <code>runtime.media</code>, the media subsystem never initializes.</p>
<h3 style="margin-top:0.8em;">Subagent Late-Binding</h3>
<p><code>runtime.subagent</code> throws outside a gateway request. Binds via:</p>
<pre style="font-size:0.75em;"><code class="language-typescript">Symbol.for("openclaw.gatewaySubagent")</code></pre>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 7: HOOKS -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">7</div>
<h2>Hooks</h2>
<p class="dim" style="font-size:0.7em;">The Event Pipeline</p>
</section>
<section>
<h2>Hook Lifecycle</h2>
<p class="key-file">src/plugins/hooks.ts</p>
<div class="diagram">Inbound message arrives
v
┌─ <span class="hl">message_received</span> ──────────── (observe)
├─ <span class="hl">before_agent_start</span> ─────── (inject system context)
├─ <span class="hl">before_model_resolve</span> ───── (override LLM selection)
├─ <span class="hl">before_prompt_build</span> ────── (modify prompt assembly)
├─ <span class="hl3">llm_input</span> ─────────────────── (observe request to LLM)
├─ <span class="hl2">before_tool_call</span> ────────── (intercept/modify tools)
│ └─ <span class="hl2">after_tool_call</span> ─────── (observe tool results)
├─ <span class="hl3">llm_output</span> ────────────────── (observe LLM response)
├─ <span class="hl4">message_sending</span> ─────────── (intercept outbound)
└─ <span class="hl4">message_sent</span> ──────────────── (observe delivery)</div>
</section>
<section>
<h2>Hook Execution Model</h2>
<pre><code class="language-typescript">// Registration
api.registerHook("before_agent_start", handler, { priority: 10 });
// Execution (internal)
const results = await runPluginHooks(
registry, "before_agent_start", event, {
mergeStrategy: "accumulate",
errorHandling: "catch-log",
}
);</code></pre>
<table style="margin-top:0.8em;">
<tr><th>Property</th><th>Behavior</th></tr>
<tr><td><span class="hl">Priority ordering</span></td><td>Higher priority hooks execute first</td></tr>
<tr><td><span class="hl3">Merge strategies</span></td><td><code>accumulate</code> (concat), <code>last-wins</code>, <code>first-wins</code></td></tr>
<tr><td><span class="hl2">Error isolation</span></td><td>catch + log — one plugin cannot crash others</td></tr>
<tr><td><span class="hl4">Permission gates</span></td><td><code>allowPromptInjection</code> restricts mutations</td></tr>
</table>
</section>
<!-- ============================================================ -->
<!-- SECTION 8: GLOBAL STATE -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">8</div>
<h2>Global State</h2>
<p class="dim" style="font-size:0.7em;">Symbol.for across CJS/ESM</p>
</section>
<section>
<h2>Process-Global Registry</h2>
<p class="key-file">src/plugins/runtime.ts</p>
<pre><code class="language-typescript">const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState");
// State shape
{
registry: PluginRegistry | null,
httpRouteRegistry: PluginRegistry | null,
version: number // incremented on changes
}</code></pre>
<ul style="margin-top:0.6em;">
<li><code>setActivePluginRegistry()</code> — swap + notify observers</li>
<li><code>getActivePluginRegistry()</code> — read current</li>
<li><code>pinActivePluginHttpRouteRegistry()</code> — lock routes at gateway startup</li>
<li>Version number detects changes without diffing</li>
</ul>
<p style="font-size:0.78em; color:var(--dim); margin-top:0.6em;">
<code>Symbol.for</code> works across CJS + ESM module graph copies — the only reliable cross-boundary global in Node.js
</p>
</section>
<!-- ============================================================ -->
<!-- SECTION 9: COMPLETE FLOW -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">9</div>
<h2>Complete Data Flow</h2>
</section>
<section>
<h2>Build Time</h2>
<div class="diagram" style="font-size:0.44em;"><span class="hl">═══════════════════════ BUILD TIME ═══════════════════════</span>
extensions/discord/index.ts
v
tsdown.config.ts (unified graph)
├──> dist/extensions/discord/index.js <span class="dim">← tree-shaken bundle</span>
├──> dist/plugin-sdk/*.js <span class="dim">← 122 subpaths</span>
└──> dist/plugin-sdk/root-alias.cjs <span class="dim">← CJS Proxy facade</span>
scripts/runtime-postbuild.mjs
├──> dist-runtime/ <span class="dim">← re-export wrappers</span>
└──> rewritten package.json <span class="dim">← .ts → .js + symlinks</span></div>
</section>
<section>
<h2>Runtime</h2>
<div class="diagram" style="font-size:0.42em;"><span class="hl3">═════════════════════════ RUNTIME ════════════════════════</span>
loadOpenClawPlugins()
├──> discoverOpenClawPlugins()
│ scan <span class="hl">bundled</span> / <span class="hl">global</span> / <span class="hl">workspace</span> / <span class="hl">config</span> dirs
│ → PluginCandidate[]
├──> loadPluginManifestRegistry()
│ read openclaw.plugin.json per candidate
│ → manifests + dedup + cache
├──> For each enabled plugin:
│ ┌──────────────────────────────────────┐
│ │ createJiti(alias: { ... }) │
│ │ │ │
│ │ v │
│ │ jiti(extensionEntry) │
│ │ │ │
│ │ v │
│ │ module.<span class="hl2">register(api)</span> │
│ │ api.registerHook(...) │
│ │ api.registerTool(...) │
│ │ api.registerChannel(...) │
│ └──────────────────────────────────────┘
├──> <span class="hl4">setActivePluginRegistry(registry)</span>
│ Symbol.for("openclaw.pluginRegistryState")
└──> Cache in LRU (128 entries)
── Request arrives ──
├──> runPluginHooks("message_received", ...)
├──> runPluginHooks("before_agent_start", ...)
├──> resolvePluginTools(context)
└──> ... (full hook lifecycle)</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 10: STATISTICS -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">10</div>
<h2>Codebase Statistics</h2>
<p class="dim" style="font-size:0.7em;">HEAD vs v2026.3.12 (March 13)</p>
</section>
<section>
<h2>Code Distribution</h2>
<div style="margin-bottom:1em;">
<p style="font-size:0.72em; color:var(--dim); margin-bottom:0.6em;">v2026.3.12 — 5 days ago</p>
<div class="stat-bar">
<span class="label">Core</span>
<div class="bar" style="width:400px; background:var(--accent3);"></div>
<span class="value" style="color:var(--accent3);">517K LOC <span class="dim">(84%)</span></span>
</div>
<div class="stat-bar">
<span class="label">Extensions</span>
<div class="bar" style="width:77px; background:var(--accent);"></div>
<span class="value" style="color:var(--accent);">100K LOC <span class="dim">(16%)</span></span>
</div>
</div>
<div>
<p style="font-size:0.72em; color:var(--dim); margin-bottom:0.6em;">Current HEAD</p>
<div class="stat-bar">
<span class="label">Core</span>
<div class="bar" style="width:340px; background:var(--accent3);"></div>
<span class="value" style="color:var(--accent3);">480K LOC <span class="dim">(71%)</span></span>
</div>
<div class="stat-bar">
<span class="label">Extensions</span>
<div class="bar" style="width:140px; background:var(--accent);"></div>
<span class="value" style="color:var(--accent);">197K LOC <span class="dim">(29%)</span></span>
</div>
</div>
<p style="font-size:0.78em; margin-top:0.8em;">Extensions: <span class="hl">42 → 76</span> (+34). Core shrank 7% as code migrated to plugins.</p>
</section>
<section>
<h2>Plugin Infrastructure Growth</h2>
<table>
<tr><th>Component</th><th>v2026.3.12</th><th>Current</th><th>Delta</th></tr>
<tr>
<td>Plugin-SDK</td>
<td>6,410 LOC</td>
<td>8,993 LOC</td>
<td><span class="hl4">+40%</span></td>
</tr>
<tr>
<td>Plugin subsystem</td>
<td>9,278 LOC</td>
<td>23,297 LOC</td>
<td><span class="hl2">+151%</span></td>
</tr>
<tr>
<td><strong>Total infra</strong></td>
<td>15,688 LOC</td>
<td>32,290 LOC</td>
<td><span class="hl">+106%</span></td>
</tr>
</table>
<div style="margin-top:1em;">
<h3>Test Coverage</h3>
<table>
<tr><th>Area</th><th>v2026.3.12</th><th>Current</th><th>Delta</th></tr>
<tr><td>Core tests</td><td>432K LOC</td><td>426K LOC</td><td><span class="dim">−1%</span></td></tr>
<tr><td>Extension tests</td><td>54K LOC</td><td>129K LOC</td><td><span class="hl4">+139%</span></td></tr>
<tr><td><strong>Total</strong></td><td>486K LOC</td><td>555K LOC</td><td><span class="hl">+14%</span></td></tr>
</table>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 11: DESIGN DECISIONS -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">11</div>
<h2>Key Design Decisions</h2>
</section>
<section>
<h2>Architecture Choices</h2>
<table style="font-size:0.68em;">
<tr><th>Decision</th><th>Rationale</th></tr>
<tr><td>Single tsdown graph</td><td>Prevents duplicate singletons (config, registry)</td></tr>
<tr><td>No per-extension build config</td><td>Entries in <code>package.json</code>; root discovers dynamically</td></tr>
<tr><td>jiti with <code>tryNative: true</code></td><td>Native <code>.js</code> in prod; <code>.ts</code> in dev</td></tr>
<tr><td>Proxy-based lazy runtime</td><td>Pay only for subsystems you use</td></tr>
<tr><td>root-alias.cjs Proxy</td><td>Fast exports inline; heavy SDK deferred</td></tr>
<tr><td><code>dist-runtime/</code> overlay</td><td>Canonical module resolution + symlinked <code>node_modules</code></td></tr>
<tr><td><code>Symbol.for</code> global state</td><td>Works across CJS + ESM boundary</td></tr>
<tr><td>1s discovery cache</td><td>Collapses bursty reloads at startup</td></tr>
<tr><td>LRU registry cache (128)</td><td>Hot reloads reuse; old entries evict</td></tr>
<tr><td>Path safety before load</td><td>Symlink rejection + ownership checks</td></tr>
</table>
</section>
<!-- ============================================================ -->
<!-- SECTION 12: AUTHOR REFERENCE -->
<!-- ============================================================ -->
<section class="section-divider" data-transition="zoom">
<div class="section-num">12</div>
<h2>Extension Author</h2>
<p class="dim" style="font-size:0.7em;">Quick Reference</p>
</section>
<section>
<h2>Minimal Extension</h2>
<div class="columns">
<div>
<pre><code class="language-plaintext">my-extension/
├── package.json
├── openclaw.plugin.json
└── index.ts</code></pre>
<pre><code class="language-json">{
"name": "@openclaw/my-extension",
"type": "module",
"openclaw": {
"extensions": ["./index.ts"]
}
}</code></pre>
</div>
<div>
<pre><code class="language-typescript">import type {
OpenClawPluginApi
} from "openclaw/plugin-sdk";
export function register(
api: OpenClawPluginApi
) {
api.registerHook(
"before_agent_start",
async (event) => {
return {
systemContext:
"Hello from my-extension"
};
}
);
}</code></pre>
</div>
</div>
</section>
<section>
<h2>Available api.register*() Methods</h2>
<table style="font-size:0.65em;">
<tr><th>Method</th><th>Registers</th></tr>
<tr><td><code>registerHook(name, handler, opts?)</code></td><td>Lifecycle hook handler</td></tr>
<tr><td><code>registerTool(factory)</code></td><td>Agent tool (created per request)</td></tr>
<tr><td><code>registerChannel(channel)</code></td><td>Messaging channel</td></tr>
<tr><td><code>registerProvider(provider)</code></td><td>LLM provider</td></tr>
<tr><td><code>registerSpeechProvider(provider)</code></td><td>TTS/STT provider</td></tr>
<tr><td><code>registerImageProvider(provider)</code></td><td>Image generation provider</td></tr>
<tr><td><code>registerMediaProvider(provider)</code></td><td>Media understanding provider</td></tr>
<tr><td><code>registerWebSearchProvider(provider)</code></td><td>Web search provider</td></tr>
<tr><td><code>registerCommand(command)</code></td><td>CLI/chat command</td></tr>
<tr><td><code>registerService(service)</code></td><td>Background service</td></tr>
<tr><td><code>registerHttpRoute(route)</code></td><td>HTTP endpoint on gateway</td></tr>
<tr><td><code>registerGatewayMethod(name, handler)</code></td><td>Gateway RPC method</td></tr>
</table>
</section>
<!-- ============================================================ -->
<!-- END -->
<!-- ============================================================ -->
<section class="title-slide" data-transition="zoom">
<h1>OpenClaw<br>Plugin Architecture</h1>
<p class="subtitle" style="margin-top:1em;">
<span class="pill">tsdown</span> builds it &nbsp;·&nbsp;
<span class="pill">jiti</span> loads it &nbsp;·&nbsp;
<span class="pill">Registry</span> wires it
</p>
<p style="font-size:0.55em; color:var(--dim); margin-top:2em;">
docs/architecture/plugin-interface.md &nbsp;·&nbsp; March 2026
</p>
</section>
</div><!-- .slides -->
</div><!-- .reveal -->
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/highlight.js"></script>
<script>
Reveal.initialize({
hash: true,
slideNumber: 'c/t',
width: 1280,
height: 720,
margin: 0.06,
center: false,
transition: 'slide',
transitionSpeed: 'fast',
backgroundTransition: 'fade',
plugins: [RevealHighlight],
highlight: {
beforeHighlight: (hljs) => {}
}
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment