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 {