add extension points coverage; add JS hooks
Alessandro committed
Feb 18, 2026 at 12:32 UTC
b43cde0b457966d3e7989c8bd3d0e607dfe336c7
5 files changed
+153
-13
plugins/README.md
+112
-8
@@ -1,6 +1,6 @@
1
# Agent Zero Plugins
2
3
-This directory contains default plugins shipped with Agent Zero.
3
+This directory contains default plugins shipped with Agent Zero and is the source of truth for the plugin system.
4
5
## Architecture
6
@@ -10,6 +10,49 @@ Agent Zero uses a convention-over-configuration plugin model:
10
- Backend owns discovery, routing, and static asset serving.
11
- Frontend uses explicit `x-extension` breakpoints plus the standard `x-component` loader.
12
13
+### Internal Components
14
+
15
+1. **Backend plugin discovery** (`python/helpers/plugins.py`)
16
+ - `get_plugin_roots()` resolves roots in priority order (`usr/plugins` first, then `plugins`).
17
+ - `list_plugins()` builds the effective set (first root wins on ID conflicts).
18
+ - `get_webui_extensions(extension_point, filters)` scans `extensions/webui/<extension_point>/`.
19
+
20
+2. **Path resolution** (`python/helpers/subagents.py`)
21
+ - `get_paths(..., include_plugins=True)` includes plugin candidates for prompts/tools.
22
+
23
+3. **Python extension runtime** (`python/helpers/extension.py`)
24
+ - `call_extensions(extension_point, agent, **kwargs)` executes extension classes.
25
+ - Searches `python/extensions/<point>/` and `plugins/*/extensions/python/<point>/`.
26
+ - Extension classes derive from `Extension` and implement `async execute()`.
27
+
28
+4. **API and static routes** (`run_ui.py`, `python/api/load_webui_extensions.py`)
29
+ - `GET /plugins/<plugin_id>/<path>` serves plugin static assets.
30
+ - Plugin APIs are mounted under `/api/plugins/<plugin_id>/<handler>`.
31
+ - `POST /api/load_webui_extensions` returns extension files for a given extension point.
32
+
33
+5. **Frontend WebUI extension runtime** (`webui/js/extensions.js`)
34
+ - HTML flow: discovers `<x-extension>` tags, calls backend API, injects `<x-component>` tags.
35
+ - JS flow: `callJsExtensions("<extension_point>", contextObject)` loads and executes plugin JS modules.
36
+ - Both HTML and JS lookups are cached per extension point.
37
+
38
+## File Structure
39
+
40
+```text
41
+plugins/
42
+ <plugin_id>/
43
+ api/ # API handlers (ApiHandler subclasses)
44
+ tools/ # Agent tools (Tool subclasses)
45
+ helpers/ # Shared Python helpers
46
+ prompts/ # Prompt templates
47
+ agents/ # Agent profiles
48
+ extensions/
49
+ python/<extension_point>/ # Python lifecycle extensions
50
+ webui/<extension_point>/ # WebUI HTML/JS hook contributions
51
+ webui/ # Full plugin-owned UI pages/components
52
+
53
+usr/plugins/<plugin_id>/ # User overrides (higher priority)
54
+```
55
+
56
## Directory Conventions
57
58
Each plugin lives in `plugins/<plugin_id>/` (or `usr/plugins/<plugin_id>/` for overrides).
@@ -72,6 +115,10 @@ Current welcome surfaces:
115
116
- `welcome-screen-start`
117
- `welcome-screen-end`
118
+- `welcome-actions-start`
119
+- `welcome-actions-end`
120
+- `welcome-banners-start`
121
+- `welcome-banners-end`
122
123
Current modal surfaces:
124
@@ -117,15 +164,23 @@ JS hooks are loaded from the same extension point structure:
164
165
Runtime code calls:
166
120
-`callJsExtensions("<extension_point>", ...args)`
167
+`callJsExtensions("<extension_point>", contextObject)`
168
169
JS hook convention:
170
- pass one mutable context object when extensions are expected to influence behavior
171
- that object is passed by reference, so mutations are visible to subsequent hooks in the same flow
172
+- hooks that support cancellation expose a `cancel: false` or `skip: false` field; set it to `true` to abort the operation
173
126
-Example:
174
+Current JS hook points:
175
128
-`set_messages_before_loop` and `set_messages_after_loop` in `webui/js/messages.js`.
176
+| Hook | File | Context fields | skip/cancel |
177
+|---|---|---|---|
178
+| `set_messages_before_loop` | messages.js | `messages, history, scrollerOptions, massRender, results` | - |
179
+| `set_messages_after_loop` | messages.js | same as above | - |
180
+| `send_message_before` | index.js | `message, attachments, context, cancel` | `cancel` |
181
+| `apply_snapshot_before` | index.js | `snapshot, willUpdateMessages, skip` | `skip` |
182
+| `open_modal_before` | modals.js | `modalPath, modal, cancel` | `cancel` |
183
+| `close_modal_before` | modals.js | `modalPath, modal, cancel` | `cancel` |
184
185
### Fine placement helpers
186
@@ -145,10 +200,56 @@ Placement behavior:
200
201
## Plugin Author Flow
202
148
-1. Pick an existing core breakpoint ID (`<x-extension id="...">`).
149
-2. Add an HTML/JS extension under `extensions/webui/<extension_point>/`.
150
-3. For HTML UI entries, use the baseline pattern: root `x-data` plus one explicit `x-move-*` directive.
151
-4. Put complete plugin pages/components in `webui/` and open them directly by path.
203
+1. Create `plugins/<plugin_id>/`.
204
+2. Add backend capabilities by convention (`api/`, `tools/`, `helpers/`, `extensions/python/`, `prompts/`, `agents/`).
205
+3. Pick a WebUI breakpoint or JS hook extension point.
206
+4. For HTML UI entries: place files under `extensions/webui/<extension_point>/`, use root `x-data` + one `x-move-*` directive.
207
+5. For JS hooks: place `*.js` files under `extensions/webui/<extension_point>/`, export a default async function.
208
+6. Place full plugin pages/components in `webui/` and open them directly by path.
209
+
210
+### Python extension example
211
+
212
+```python
213
+# plugins/my-plugin/extensions/python/monologue_end/_50_my_extension.py
214
+from python.helpers.extension import Extension
215
+
216
+class MyExtension(Extension):
217
+ async def execute(self, **kwargs):
218
+ pass
219
+```
220
+
221
+### HTML WebUI extension example
222
+
223
+```html
224
+<!-- plugins/my-plugin/extensions/webui/sidebar-quick-actions-main-start/my-button.html -->
225
+<div x-data>
226
+ <button
227
+ x-move-after=".config-button#dashboard"
228
+ class="config-button"
229
+ id="my-plugin-button"
230
+ @click="openModal('../plugins/my-plugin/webui/my-modal.html')"
231
+ title="My Plugin">
232
+ <span class="material-symbols-outlined">extension</span>
233
+ </button>
234
+</div>
235
+```
236
+
237
+### JS hook example
238
+
239
+```js
240
+// plugins/my-plugin/extensions/webui/send_message_before/transform.js
241
+export default async function(ctx) {
242
+ // prepend a tag to every outgoing message
243
+ ctx.message = "[my-plugin] " + ctx.message;
244
+}
245
+```
246
+
247
+### Full plugin UI page
248
+
249
+```html
250
+<!-- opened via openModal() or x-component -->
251
+<x-component path="../plugins/my-plugin/webui/my-modal.html"></x-component>
252
+```
253
254
## Routes
255
@@ -160,3 +261,6 @@ Placement behavior:
261
262
- User plugins in `usr/plugins/` override repo plugins by plugin ID.
263
- Runtime behavior is fully convention-driven from directory structure.
264
+- Extension point ordering between multiple plugins is currently implicit (filesystem order).
265
+- Project-specific plugin roots are not yet active (commented out in `get_plugin_roots()`).
266
+- When you need a new extension point for your plugin, submit a PR - we are actively expanding coverage based on community needs.
plugins/tests/test_webui_extension_surfaces.py
+4
@@ -44,6 +44,10 @@ SURFACE_SCENARIOS: list[tuple[str, str]] = [
44
("chat-top-end", "webui/components/chat/top-section/chat-top.html"),
45
("welcome-screen-start", "webui/components/welcome/welcome-screen.html"),
46
("welcome-screen-end", "webui/components/welcome/welcome-screen.html"),
47
+ ("welcome-actions-start", "webui/components/welcome/welcome-screen.html"),
48
+ ("welcome-actions-end", "webui/components/welcome/welcome-screen.html"),
49
+ ("welcome-banners-start", "webui/components/welcome/welcome-screen.html"),
50
+ ("welcome-banners-end", "webui/components/welcome/welcome-screen.html"),
51
("modal-shell-start", "webui/js/modals.js"),
52
("modal-shell-end", "webui/js/modals.js"),
53
]
webui/components/welcome/welcome-screen.html
+7
-1
@@ -14,6 +14,7 @@
14
<div class="welcome-content">
15
<!-- Action Cards -->
16
<div class="welcome-actions">
17
+ <x-extension id="welcome-actions-start"></x-extension>
18
<div class="welcome-action-card" @click="$store.welcomeStore.executeAction('new-chat')">
19
<span class="material-symbols-outlined welcome-action-icon">add_circle</span>
20
<h3 class="welcome-action-title">New Chat</h3>
@@ -49,6 +50,7 @@
50
<span class="material-symbols-outlined welcome-action-icon">code</span>
51
<h3 class="welcome-action-title">Visit GitHub</h3>
52
</div>
53
+ <x-extension id="welcome-actions-end"></x-extension>
54
</div>
55
56
<div class="welcome-actions-footer">
@@ -72,6 +74,7 @@
74
75
<!-- Banner Section -->
76
<div class="welcome-banners" x-show="$store.welcomeStore.banners && $store.welcomeStore.banners.length > 0">
77
+ <x-extension id="welcome-banners-start"></x-extension>
78
<template x-for="banner in $store.welcomeStore.sortedBanners" :key="banner.id">
79
<div class="welcome-banner" :class="$store.welcomeStore.getBannerClass(banner.type)">
80
<!-- Banner Icon -->
@@ -94,6 +97,7 @@
97
</button>
98
</div>
99
</template>
100
+ <x-extension id="welcome-banners-end"></x-extension>
101
</div>
102
</div>
103
<x-extension id="welcome-screen-end"></x-extension>
@@ -116,7 +120,9 @@
120
position: relative;
121
overflow: hidden;
122
}
119
- .welcome-container > x-extension {
123
+ .welcome-container > x-extension,
124
+ .welcome-actions > x-extension,
125
+ .welcome-banners > x-extension {
126
display: contents;
127
}
128
webui/index.js
+17
-2
@@ -1,5 +1,6 @@
1
import * as msgs from "/js/messages.js";
2
import * as api from "/js/api.js";
3
+import { callJsExtensions } from "/js/extensions.js";
4
import * as css from "/js/css.js";
5
import { sleep } from "/js/sleep.js";
6
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
@@ -38,10 +39,16 @@ let skipOneSpeech = false;
39
40
export async function sendMessage() {
41
try {
41
- const message = inputStore.message.trim();
42
- const attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
42
+ let message = inputStore.message.trim();
43
+ let attachmentsWithUrls = attachmentsStore.getAttachmentsForSending();
44
const hasAttachments = attachmentsWithUrls.length > 0;
45
46
+ const sendCtx = { message, attachments: attachmentsWithUrls, context, cancel: false };
47
+ await callJsExtensions("send_message_before", sendCtx);
48
+ if (sendCtx.cancel) return;
49
+ message = sendCtx.message;
50
+ attachmentsWithUrls = sendCtx.attachments;
51
+
52
// If empty input but has queued messages, send all queued
53
if (!message && !hasAttachments && messageQueueStore.hasQueue) {
54
await messageQueueStore.sendAll();
@@ -311,6 +318,14 @@ export async function applySnapshot(snapshot, options = {}) {
318
return { updated: false };
319
}
320
321
+ const snapCtx = {
322
+ snapshot,
323
+ willUpdateMessages: lastLogVersion != snapshot.log_version,
324
+ skip: false,
325
+ };
326
+ await callJsExtensions("apply_snapshot_before", snapCtx);
327
+ if (snapCtx.skip) return { updated: false };
328
+
329
// If the chat has been reset, reset cursors and request a resync from the caller.
330
// Note: on first snapshot after a context switch, lastLogGuid is intentionally empty,
331
// so the mismatch is expected and should not trigger a second state_request/poll.
webui/js/modals.js
+13
-2
@@ -1,5 +1,6 @@
1
// Import the component loader and page utilities
2
import { importComponent } from "/js/components.js";
3
+import { callJsExtensions } from "/js/extensions.js";
4
5
// Modal functionality
6
const modalStack = [];
@@ -106,12 +107,18 @@ function createModalElement(path) {
107
}
108
109
// Function to open modal with content from URL
109
-export function openModal(modalPath, beforeClose = null) {
110
+export async function openModal(modalPath, beforeClose = null) {
111
+ const openCtx = { modalPath, modal: null, cancel: false };
112
+ await callJsExtensions("open_modal_before", openCtx);
113
+ if (openCtx.cancel) return;
114
+ modalPath = openCtx.modalPath;
115
+
116
return new Promise((resolve) => {
117
try {
118
// Create new modal instance
119
const modal = createModalElement(modalPath);
120
modal.beforeClose = beforeClose;
121
+ openCtx.modal = modal;
122
123
new MutationObserver(
124
(_, o) =>
@@ -175,7 +182,7 @@ export function openModal(modalPath, beforeClose = null) {
182
}
183
184
// Function to close modal
178
-export function closeModal(modalPath = null) {
185
+export async function closeModal(modalPath = null) {
186
if (modalStack.length === 0) return;
187
188
let modalIndex = modalStack.length - 1; // Default to last modal
@@ -193,6 +200,10 @@ export function closeModal(modalPath = null) {
200
modal = modalStack[modalStack.length - 1];
201
}
202
203
+ const closeCtx = { modalPath: modalPath ?? null, modal, cancel: false };
204
+ await callJsExtensions("close_modal_before", closeCtx);
205
+ if (closeCtx.cancel) return false;
206
+
207
const canClose = async () => {
208
if (!modal.beforeClose) return true;
209
try {