feat(plugins): Add extensibility and hooks system to plugin API

Add @extension.extensible decorators to all plugin API handler methods and core plugin functions to enable extension points. Implement plugin hooks system allowing plugins to define custom behavior via hooks.py file. Add call_plugin_hook function to execute plugin-specific hooks for events like uninstall, save_plugin_config, and get_plugin_config. Introduce uninstall_plugin function that calls uninstall hook before deletion. Move circular
This commit is contained in:
frdel 2026-03-12 13:21:33 +01:00
parent bcbce781b8
commit a48ac95a29
15 changed files with 339 additions and 79 deletions

View file

@ -128,6 +128,9 @@ Key Files:
- Location: Always develop new plugins in usr/plugins/.
- Manifest: Every plugin requires a plugin.yaml with name, description, version, and optionally settings_sections, per_project_config, per_agent_config, and always_enabled.
- Discovery: Conventions based on folder names (api/, tools/, webui/, extensions/).
- Runtime hooks: Plugins may also expose hooks in hooks.py, callable by the framework through helpers.plugins.call_plugin_hook(...).
- Hook runtime: hooks.py executes inside the Agent Zero framework Python environment, so sys.executable -m pip installs dependencies into that same framework runtime.
- Environment targeting: If a plugin needs packages or binaries for the separate agent execution runtime or system environment, it must explicitly switch environments in a subprocess by targeting the correct interpreter, virtualenv, or package manager.
- Settings: Use get_plugin_config(plugin_name, agent=agent) to retrieve settings. Plugins can expose a UI for settings via webui/config.html. Plugin settings modals instantiate a local context from $store.pluginSettingsPrototype; bind plugin fields to config.* and use context.* for modal-level state and actions. For plugins wrapping core settings, set context.saveMode = 'core' in x-init.
- Activation: Global and scoped activation rules are stored as .toggle-1 (ON) and .toggle-0 (OFF). Scoped rules are handled via the plugin "Switch" modal.

View file

@ -5,7 +5,7 @@ import sys
from datetime import datetime, timezone
from helpers.api import ApiHandler, Request, Response
from helpers import plugins, files
from helpers import plugins, files, extension
class Plugins(ApiHandler):
@ -53,6 +53,7 @@ class Plugins(ApiHandler):
return Response(status=400, response=f"Unknown action: {action}")
@extension.extensible
def _get_config(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
project_name = input.get("project_name", "")
@ -90,6 +91,7 @@ class Plugins(ApiHandler):
"data": settings,
}
@extension.extensible
def _get_toggle_status(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
project_name = input.get("project_name", "")
@ -140,6 +142,7 @@ class Plugins(ApiHandler):
"loaded_path": "",
}
@extension.extensible
def _list_configs(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
asset_type = input.get("asset_type", "config")
@ -160,6 +163,7 @@ class Plugins(ApiHandler):
return {"ok": True, "data": configs}
@extension.extensible
def _delete_config(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
path = input.get("path", "")
@ -196,12 +200,13 @@ class Plugins(ApiHandler):
return {"ok": True}
@extension.extensible
def _delete_plugin(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
if not plugin_name:
return Response(status=400, response="Missing plugin_name")
try:
plugins.delete_plugin(plugin_name)
plugins.uninstall_plugin(plugin_name)
except FileNotFoundError as e:
return Response(status=404, response=str(e))
except ValueError as e:
@ -210,6 +215,7 @@ class Plugins(ApiHandler):
return Response(status=500, response=f"Failed to delete plugin: {str(e)}")
return {"ok": True}
@extension.extensible
def _get_default_config(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
if not plugin_name:
@ -217,6 +223,7 @@ class Plugins(ApiHandler):
settings = plugins.get_default_plugin_config(plugin_name)
return {"ok": True, "data": settings or {}}
@extension.extensible
def _save_config(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
project_name = input.get("project_name", "")
@ -229,6 +236,7 @@ class Plugins(ApiHandler):
plugins.save_plugin_config(plugin_name, project_name, agent_profile, settings)
return {"ok": True}
@extension.extensible
def _toggle_plugin(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
enabled = input.get("enabled")
@ -246,6 +254,7 @@ class Plugins(ApiHandler):
)
return {"ok": True}
@extension.extensible
def _get_doc(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
doc = input.get("doc", "")
@ -265,6 +274,7 @@ class Plugins(ApiHandler):
return {"ok": True, "content": files.read_file(file_path), "filename": filename}
@extension.extensible
def _run_init_script(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
if not plugin_name:
@ -311,6 +321,7 @@ class Plugins(ApiHandler):
"executed_at": executed_at,
}
@extension.extensible
def _get_init_exec(self, input: dict) -> dict | Response:
plugin_name = input.get("plugin_name", "")
if not plugin_name:

View file

@ -25,6 +25,7 @@ Each plugin lives in usr/plugins/<plugin_name>/.
usr/plugins/<plugin_name>/
├── plugin.yaml # Required: Title, version, settings + activation metadata
├── initialize.py # Optional: one-time setup script (dependencies, models, etc.)
├── hooks.py # Optional: runtime hook functions callable by the framework
├── default_config.yaml # Optional: fallback settings defaults
├── README.md # Optional: shown in Plugin List UI
├── LICENSE # Optional: shown in Plugin List UI
@ -68,6 +69,36 @@ Field reference:
- `per_agent_config`: Enables agent-profile-scoped settings and toggle rules
- `always_enabled`: Forces ON and disables toggle controls in the UI (reserved for framework use)
### hooks.py (framework runtime hooks)
Plugins can include an optional `hooks.py` file at the plugin root. Agent Zero loads this module on demand and calls exported functions by name through `helpers.plugins.call_plugin_hook(...)`.
- `hooks.py` runs inside the **Agent Zero framework runtime and Python environment**, not the separate agent execution environment.
- Use it for framework-internal operations such as install-time setup, plugin registration work, filesystem preparation, cache updates, or other tasks that need access to Agent Zero internals.
- Hook functions may be synchronous or async. Async hooks are awaited by the framework.
- Hook modules are cached until plugin caches are cleared, so changes may require a plugin refresh/reload cycle.
Current example: the plugin installer calls `install()` from `hooks.py` after a plugin is copied into place.
### Runtime and dependency implications
- If `hooks.py` installs Python packages with `sys.executable -m pip`, those packages are installed into the **same Python environment that runs Agent Zero itself**.
- This is the correct place for Python dependencies that your plugin's backend code needs while running inside the framework runtime.
- It is **not** the right place for dependencies meant only for the separate agent execution runtime or for arbitrary system-level tooling.
If your plugin needs to install packages or binaries for the agent execution environment instead of the framework runtime, launch a subprocess that explicitly activates or targets that other environment first. In practice this means invoking the correct interpreter or shell for that environment rather than relying on the current process environment. For example:
- target a specific Python interpreter path for that runtime
- activate the desired virtualenv inside a subprocess shell command before running `pip`
- invoke the appropriate package manager from a subprocess prepared for that environment
In Docker deployments, this distinction is especially important:
- Framework runtime: `/opt/venv-a0`
- Agent execution runtime: `/opt/venv`
So a `hooks.py` install step affects `/opt/venv-a0` unless you intentionally switch to `/opt/venv` (or another target) inside your subprocess.
---
## 3. Frontend Extensions

View file

@ -50,6 +50,7 @@ Field reference:
usr/plugins/<plugin_name>/
├── plugin.yaml
├── initialize.py # optional one-time setup script
├── hooks.py # optional runtime hook functions callable by the framework
├── default_config.yaml # optional defaults
├── README.md # optional, shown in Plugin List UI
├── LICENSE # optional, shown in Plugin List UI
@ -97,6 +98,33 @@ if __name__ == "__main__":
Return `0` on success, non-zero on failure. Print progress for user feedback. Use `sys.executable` for pip commands.
## Runtime Hooks (`hooks.py`)
Plugins can also include an optional `hooks.py` at the plugin root. Agent Zero loads this module on demand and calls exported functions by name through `helpers.plugins.call_plugin_hook(...)`.
- `hooks.py` executes inside the **Agent Zero framework runtime and Python environment**.
- Use it for framework-internal operations such as install hooks, registration, cache preparation, file setup, or other work that needs direct access to framework internals.
- Hook functions may be synchronous or async.
- Hook modules are cached, so edits may require a plugin refresh or cache clear before changes are picked up.
Current built-in usage: the plugin installer calls `install()` from `hooks.py` after copying a plugin into place.
### Dependency and environment behavior
- If `hooks.py` runs `sys.executable -m pip install ...`, it installs into the **same Python environment that is currently running Agent Zero**.
- That is the correct target for dependencies needed by your plugin's backend code inside the framework runtime.
- It is not automatically the right target for packages intended only for the separate agent execution runtime or for system-level binaries.
If you need to install into a different environment, do it explicitly from a subprocess. In practice, that means targeting the correct interpreter or activating the correct environment inside the subprocess before running `pip` or another package manager.
Examples of the right approach:
- call a specific Python executable for the target runtime
- activate the target virtualenv in a subprocess shell command before invoking `pip`
- run OS-level package installation from a subprocess prepared for the intended environment
In Docker deployments, `hooks.py` normally affects the framework runtime at `/opt/venv-a0`, while the agent execution runtime is `/opt/venv`.
## Settings Resolution
Plugin settings are resolved by scope. Higher priority overrides lower priority:

View file

@ -16,7 +16,7 @@ from helpers import files, cache
ThreadLockType = Union[threading.Lock, threading.RLock]
CACHE_AREA = "api_handlers(api)(plugins)"
CACHE_AREA = "api_handlers(api)(plugins)(extensions)"
cache.toggle_area(CACHE_AREA, False) # cache off for now
Input = dict

View file

@ -18,6 +18,13 @@ def toggle_area(area: str, enabled: bool) -> None:
_enabled_areas[area] = enabled
def has(area: str, key: str) -> bool:
if not _is_enabled(area):
return False
with _lock:
return key in _cache.get(area, {})
def add(area: str, key: str, data: Any) -> None:
if not _is_enabled(area):
return

View file

@ -1,7 +1,7 @@
from abc import abstractmethod
from typing import Any, Awaitable, Type, cast
from helpers import extract_tools, files
from helpers import cache, plugins, subagents
from helpers import cache, subagents
from typing import TYPE_CHECKING
from functools import wraps
import inspect
@ -262,4 +262,4 @@ def _get_extensions(folder: str):
classes = extract_tools.load_classes_from_folder(folder, "*", Extension)
cache.add(_CACHE_AREA, folder, classes)
return classes
return classes

View file

@ -15,7 +15,15 @@ from typing import (
TypedDict,
)
from helpers import files, notification, print_style, yaml as yaml_helper, cache
from helpers import (
files,
notification,
print_style,
yaml as yaml_helper,
cache,
extension,
extract_tools,
)
from pydantic import BaseModel, Field
from helpers.defer import DeferredTask
@ -29,6 +37,7 @@ _META_TARGET_RE = re.compile(
re.IGNORECASE,
)
type ToggleState = Literal["enabled", "disabled", "advanced"]
@ -44,6 +53,10 @@ CONFIG_DEFAULT_FILE_NAME = "default_config.yaml"
DISABLED_FILE_NAME = ".toggle-0"
ENABLED_FILE_NAME = ".toggle-1"
TOGGLE_FILE_PATTERN = ".toggle-[01]"
HOOKS_SCRIPT = "hooks.py"
HOOKS_CACHE_AREA = "plugin_hooks(plugins)"
_last_frontend_reload_notification_at = 0.0
@ -77,6 +90,7 @@ class PluginListItem(BaseModel):
toggle_state: ToggleState = "disabled"
@extension.extensible
def after_plugin_change(plugin_names: list[str] | None = None):
clear_plugin_cache()
send_frontend_reload_notification(plugin_names)
@ -192,6 +206,14 @@ def find_plugin_dir(plugin_name: str):
return None
@extension.extensible
def uninstall_plugin(plugin_name):
# call the uninstall hook if any
call_plugin_hook(plugin_name, "uninstall")
# then delete
delete_plugin(plugin_name)
@extension.extensible
def delete_plugin(plugin_name: str):
plugin_dir = find_plugin_dir(plugin_name)
if not plugin_dir:
@ -199,7 +221,9 @@ def delete_plugin(plugin_name: str):
custom_plugins_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
if not files.is_in_dir(plugin_dir, custom_plugins_dir):
raise ValueError("Only custom plugins can be deleted")
send_frontend_reload_notification([plugin_name]) # send before deletion to properly check the extensions, second notification will be skipped automatically
send_frontend_reload_notification(
[plugin_name]
) # send before deletion to properly check the extensions, second notification will be skipped automatically
files.delete_dir(plugin_dir)
after_plugin_change([plugin_name])
@ -298,7 +322,6 @@ def get_toggle_state(plugin_name: str) -> ToggleState:
else "disabled"
)
# additional toggles in project/agent directories, return advanced
if meta.per_agent_config or meta.per_project_config:
configs = find_plugin_assets(
@ -316,6 +339,7 @@ def get_toggle_state(plugin_name: str) -> ToggleState:
return state
@extension.extensible
def toggle_plugin(
plugin_name: str,
enabled: bool,
@ -352,6 +376,7 @@ def toggle_plugin(
after_plugin_change([plugin_name])
@extension.extensible
def get_plugin_config(
plugin_name: str,
agent: Agent | None = None,
@ -380,35 +405,75 @@ def get_plugin_config(
file_path = files.get_abs_path(
find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
)
result = None
if file_path and files.exists(file_path):
return (
result = (
json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
)(files.read_file(file_path))
return None
# call plugin hook to modify the standard result if needed
new_result = call_plugin_hook(
plugin_name,
"save_plugin_config",
result=result,
agent=agent,
project_name=project_name,
agent_profile=agent_profile,
)
if new_result is not None:
return new_result
return result
def get_default_plugin_config(plugin_name: str):
file_path = files.get_abs_path(
find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
)
if file_path and files.exists(file_path):
return (
# call plugin hook to get the result
result = call_plugin_hook(
plugin_name,
"save_plugin_config",
file_path = file_path
)
# or do standard load
if result is None and file_path and files.exists(file_path):
result = (
json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
)(files.read_file(file_path))
return None
return result
@extension.extensible
def save_plugin_config(
plugin_name: str, project_name: str, agent_profile: str, settings: dict
):
file_path = determine_plugin_asset_path(
plugin_name, project_name, agent_profile, CONFIG_FILE_NAME
)
if file_path:
files.write_file(file_path, json.dumps(settings))
# call plugin hook to get the result first
new_settings = call_plugin_hook(
plugin_name,
"save_plugin_config",
result=None,
project_name=project_name,
agent_profile=agent_profile,
settings=settings,
)
# or do standard load
if new_settings is not None and file_path:
files.write_file(file_path, json.dumps(new_settings))
after_plugin_change([plugin_name])
def find_plugin_asset(
plugin_name: str, *subpaths: str, project_name="", agent_profile=""
):
@ -593,3 +658,31 @@ def send_frontend_reload_notification(plugin_names: list[str] | None = None):
)
DeferredTask().start_task(_send_later)
def call_plugin_hook(plugin_name: str, hook_name: str, *args, **kwargs):
hooks = None
# use cached hooks if enabled
if not cache.has(HOOKS_CACHE_AREA, plugin_name):
hooks_script = files.get_abs_path(find_plugin_dir(plugin_name), HOOKS_SCRIPT)
hooks = (
extract_tools.import_module(hooks_script)
if files.exists(hooks_script)
else None
)
cache.add(HOOKS_CACHE_AREA, plugin_name, hooks)
else:
hooks = cache.get(HOOKS_CACHE_AREA, plugin_name)
if not hooks:
return
hook = getattr(hooks, hook_name, None)
if not hook:
return
if asyncio.iscoroutinefunction(hook):
return asyncio.run(hook(*args, **kwargs))
return hook(*args, **kwargs)

View file

@ -252,7 +252,7 @@ def _merge_agent_list_items(
def get_agents_roots() -> list[str]:
from helpers import plugins
# from helpers import plugins
plugin_agents = plugins.get_enabled_plugin_paths(None, "agents")
project_agents = files.find_existing_paths_by_pattern("usr/projects/*/.a0proj/agents")
@ -378,7 +378,7 @@ def get_paths(
# plugin agents/<profile>/...
if include_plugins:
from helpers import plugins
# from helpers import plugins
for plugin_dir in plugins.get_enabled_plugin_paths(agent, "agents", profile_name):
path = files.get_abs_path(plugin_dir, *subpaths)
if (not must_exist_completely) or files.exists(files.get_abs_path(plugin_dir, *check_subpaths)):
@ -397,7 +397,7 @@ def get_paths(
if include_plugins:
# plugins/*/subpaths...
from helpers import plugins
# from helpers import plugins
for plugin_dir in plugins.get_enabled_plugin_paths(agent):
path = files.get_abs_path(plugin_dir, *subpaths)
@ -412,3 +412,7 @@ def get_paths(
paths.append(path)
return paths
# end-of-file imports to prevent circular imports
from helpers import plugins

View file

@ -45,6 +45,17 @@ always_enabled: false
Plugins can include an optional `initialize.py` at the plugin root for one-time setup such as installing dependencies or downloading models. Users trigger it via the **Init** button in the Plugin List UI. The script should return `0` on success and print progress messages for user feedback.
## Runtime Hooks (`hooks.py`)
Plugins can also include an optional `hooks.py` at the plugin root. The framework loads it on demand and can call exported hook functions by name through `helpers.plugins.call_plugin_hook(...)`.
- `hooks.py` runs inside the **Agent Zero framework runtime and Python environment**.
- Use it for framework-internal work such as install hooks, cache preparation, registration, or filesystem setup.
- If it runs `sys.executable -m pip install ...`, packages are installed into the same Python environment that runs Agent Zero.
- If you need to install into the separate agent runtime or into the system environment, explicitly target that environment from a subprocess by selecting the correct interpreter, virtualenv, or package manager.
In Docker, `hooks.py` normally affects `/opt/venv-a0`; the agent execution runtime is `/opt/venv`.
## Plugin Index & Community Sharing
The **Plugin Index** at https://github.com/agent0ai/a0-plugins is the community-maintained registry of plugins available to all Agent Zero users.

View file

@ -2,15 +2,15 @@ from __future__ import annotations
import json
import os
import shutil
import time
from turtle import stamp
import urllib.request
import uuid
import zipfile
from pathlib import Path
from typing import Any
from helpers import files
from helpers import files, print_style, plugins
from helpers import yaml as yaml_helper
from helpers.plugins import (
META_FILE_NAME,
@ -21,6 +21,7 @@ from helpers.plugins import (
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
def _get_user_plugins_dir() -> str:
"""Return absolute path to usr/plugins/."""
return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
@ -33,7 +34,7 @@ def _get_plugin_name(meta: PluginMetadata) -> str:
return plugin_name
def validate_plugin_dir(path: str, plugin_name:str="") -> PluginMetadata:
def validate_plugin_dir(path: str, plugin_name: str = "") -> PluginMetadata:
"""Check directory contains plugin.yaml and return parsed metadata.
Raises ValueError if plugin.yaml is missing or invalid."""
meta_path = os.path.join(path, META_FILE_NAME)
@ -44,7 +45,9 @@ def validate_plugin_dir(path: str, plugin_name:str="") -> PluginMetadata:
data = yaml_helper.loads(content)
model = PluginMetadata.model_validate(data)
if plugin_name and plugin_name != model.name:
raise ValueError(f"Plugin name is incorrect: expected '{plugin_name}', got '{model.name}'. The author needs to correct this in the plugin.yaml file.")
raise ValueError(
f"Plugin name is incorrect: expected '{plugin_name}', got '{model.name}'. The author needs to correct this in the plugin.yaml file."
)
return model
@ -89,36 +92,47 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
"""Extract ZIP, find plugin.yaml, move its parent to usr/plugins/.
Returns dict with plugin name and metadata.
Cleans up tmp files regardless of outcome."""
base_tmp = files.get_abs_path("tmp", "plugin_installs")
os.makedirs(base_tmp, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
extract_dir = os.path.join(base_tmp, f"extract_{stamp}")
os.makedirs(extract_dir, exist_ok=True)
temp_name = f"tmp_plugin_{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
extract_dir = files.get_abs_path(files.TEMP_DIR, "plugin_installs", temp_name)
extract_dir = files.create_dir_safe(extract_dir)
dest = ""
try:
# Extract with path traversal protection
try:
# Extract with path traversal protection
with zipfile.ZipFile(zip_path, "r") as z:
real_extract = os.path.realpath(extract_dir)
for member in z.namelist():
member_path = os.path.realpath(os.path.join(extract_dir, member))
if not member_path.startswith(real_extract + os.sep) and member_path != real_extract:
if not (files.is_in_dir(member_path, extract_dir)):
raise ValueError(f"Unsafe path in archive: {member}")
z.extractall(extract_dir)
except zipfile.BadZipFile:
raise ValueError("The uploaded file is not a valid ZIP archive")
# Find plugin.yaml
plugin_root = _find_plugin_root(extract_dir)
meta = validate_plugin_dir(plugin_root)
plugin_name = _get_plugin_name(meta)
# Find plugin.yaml
plugin_root = _find_plugin_root(extract_dir)
meta = validate_plugin_dir(plugin_root)
plugin_name = _get_plugin_name(meta)
check_plugin_conflict(plugin_name)
check_plugin_conflict(plugin_name)
# Move to usr/plugins/
dest = os.path.join(_get_user_plugins_dir(), plugin_name)
files.create_dir(os.path.dirname(dest))
files.move_dir(plugin_root, dest)
except Exception as e:
print_style.PrintStyle.error(f"Failed to validate plugin: {e}")
files.delete_dir(extract_dir)
raise
# run installation hook
try:
run_install_hook(plugin_name)
except Exception as e:
print_style.PrintStyle.error(
f"Failed to run installation hook for {plugin_name}: {e}"
)
files.delete_dir(dest)
raise
# Move to usr/plugins/
dest = os.path.join(_get_user_plugins_dir(), plugin_name)
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.move(plugin_root, dest)
after_plugin_change([plugin_name])
return {
@ -129,50 +143,59 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
}
finally:
# Cleanup: extracted files and the archive
shutil.rmtree(extract_dir, ignore_errors=True)
try:
os.unlink(zip_path)
except OSError:
files.delete_dir(extract_dir)
files.delete_file(zip_path)
except Exception as e:
pass
def install_from_git(url: str, token: str | None = None, plugin_name: str="") -> dict:
def install_from_git(url: str, token: str | None = None, plugin_name: str = "") -> dict:
"""Clone git repo into usr/plugins/, validate plugin.yaml.
Returns dict with plugin name and metadata."""
from helpers.git import clone_repo
temp_name = f"tmp_plugin_{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
dest = files.get_abs_path(files.TEMP_DIR, "plugins_installer", temp_name)
files.create_dir_safe(dest)
git_dir = files.get_abs_path(files.TEMP_DIR, "plugins_installer", temp_name)
try:
clone_repo(url, dest, token=token or None)
files.create_dir_safe(git_dir)
clone_repo(url, git_dir, token=token or None)
meta = validate_plugin_dir(git_dir, plugin_name=plugin_name)
plugin_name = _get_plugin_name(meta)
check_plugin_conflict(plugin_name)
final_dir = os.path.join(_get_user_plugins_dir(), plugin_name)
files.move_dir(git_dir, final_dir)
except Exception as e:
# Cleanup partial clone
shutil.rmtree(dest, ignore_errors=True)
raise ValueError(f"Git clone failed: {e}") from e
try:
meta = validate_plugin_dir(dest, plugin_name=plugin_name)
except ValueError:
# No plugin.yaml — remove cloned repo
shutil.rmtree(dest, ignore_errors=True)
print_style.PrintStyle.error(f"Failed to validate plugin: {e}")
files.delete_dir(git_dir)
raise
# run installation hook
try:
run_install_hook(plugin_name)
except Exception as e:
print_style.PrintStyle.error(
f"Failed to run installation hook for {plugin_name}: {e}"
)
files.delete_dir(final_dir)
raise
plugin_name = _get_plugin_name(meta)
check_plugin_conflict(plugin_name)
final_dest = os.path.join(_get_user_plugins_dir(), plugin_name)
files.move_dir(dest, final_dest)
after_plugin_change([plugin_name])
return {
"success": True,
"plugin_name": plugin_name,
"title": meta.title or plugin_name,
"path": files.deabsolute_path(final_dest),
"path": files.deabsolute_path(final_dir),
}
def run_install_hook(plugin_name: str):
return plugins.call_plugin_hook(plugin_name, "install")
def get_marketplace_index() -> dict[str, Any]:
"""Return the plugin index plus installed marketplace keys."""
index_data = fetch_plugin_index()
@ -200,4 +223,4 @@ def fetch_plugin_index() -> dict:
req = urllib.request.Request(index_url, headers={"User-Agent": "AgentZero"})
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
return data
return data

View file

@ -61,36 +61,36 @@
<div class="pi-hero-manage">
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_main_screen">
<button type="button" class="button"
@click="$store.pluginInstallStore.manageOpenPlugin()">
@click="$store.pluginInstallStore.handleOpenPlugin()">
<span class="icon material-symbols-outlined">dashboard</span> Open
</button>
</template>
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_config_screen">
<button type="button" class="button"
@click="$store.pluginInstallStore.manageOpenConfig()">
@click="$store.pluginInstallStore.handleOpenConfig()">
<span class="icon material-symbols-outlined">settings</span> Config
</button>
</template>
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_readme">
<button type="button" class="button"
@click="$store.pluginInstallStore.manageOpenDoc('readme')">
@click="$store.pluginInstallStore.handleOpenDoc('readme')">
<span class="icon material-symbols-outlined">description</span> README
</button>
</template>
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_license">
<button type="button" class="button"
@click="$store.pluginInstallStore.manageOpenDoc('license')">
@click="$store.pluginInstallStore.handleOpenDoc('license')">
<span class="icon material-symbols-outlined">gavel</span> License
</button>
</template>
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_init_script">
<button type="button" class="button"
@click="$store.pluginInstallStore.manageOpenInit()">
@click="$store.pluginInstallStore.handleOpenInit()">
<span class="icon material-symbols-outlined">terminal</span> Init
</button>
</template>
<button type="button" class="button"
@click="$store.pluginInstallStore.manageOpenInfo()">
@click="$store.pluginInstallStore.handleOpenInfo()">
<span class="icon material-symbols-outlined">info</span> Info
</button>
</div>
@ -135,9 +135,18 @@
</template>
<template x-if="$store.pluginInstallStore.selectedPlugin.installed && $store.pluginInstallStore.installedPluginInfo?.is_custom">
<button type="button" class="pi-btn-uninstall"
@click="$confirmClick($event, () => $store.pluginInstallStore.manageDeletePlugin())">
<span class="material-symbols-outlined">delete</span>
<span>Uninstall</span>
@click="$confirmClick($event, () => $store.pluginInstallStore.handleDeletePlugin())"
:disabled="$store.pluginInstallStore.loading">
<span class="pi-btn-loading" x-show="$store.pluginInstallStore.loading">
<span class="spinner"></span>
<span x-text="$store.pluginInstallStore.loadingMessage || 'Uninstalling...'"></span>
</span>
<span x-show="!$store.pluginInstallStore.loading">
<span class="material-symbols-outlined">delete</span>
</span>
<span x-show="!$store.pluginInstallStore.loading">
<span>Uninstall</span>
</span>
</button>
</template>
<template x-if="$store.pluginInstallStore.selectedPlugin.discussion">
@ -416,6 +425,11 @@
color: #fff;
}
.pi-btn-uninstall:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pi-btn-uninstall .material-symbols-outlined {
font-size: 1.3rem;
}

View file

@ -452,38 +452,43 @@ const model = {
},
manageOpenPlugin() {
handleOpenPlugin() {
const info = this.installedPluginInfo;
if (!info || !info.name || !info.has_main_screen) return;
openModal(`/plugins/${info.name}/webui/main.html`);
},
async manageOpenConfig() {
async handleOpenConfig() {
if (this.installedPluginInfo) {
await pluginListStore.openPluginConfig(this.installedPluginInfo);
}
},
async manageOpenDoc(doc) {
async handleOpenDoc(doc) {
if (this.installedPluginInfo) {
await pluginListStore.openPluginDoc(this.installedPluginInfo, doc);
}
},
manageOpenInfo() {
handleOpenInfo() {
if (this.installedPluginInfo) {
pluginListStore.openPluginInfo(this.installedPluginInfo);
}
},
manageOpenInit() {
handleOpenInit() {
if (this.installedPluginInfo) {
pluginInitStore.open(this.installedPluginInfo);
}
},
async manageDeletePlugin() {
if (this.installedPluginInfo) {
async handleDeletePlugin() {
if (!this.installedPluginInfo) return;
try {
this.loading = true;
this.loadingMessage = "Uninstalling plugin...";
await pluginListStore.deletePlugin(this.installedPluginInfo);
const currentPlugin = this.selectedPlugin;
if (currentPlugin) {
@ -493,6 +498,9 @@ const model = {
);
}
this.installedPluginInfo = null;
} finally {
this.loading = false;
this.loadingMessage = "";
}
},

View file

@ -178,6 +178,7 @@ save_plugin_config(
/a0/usr/plugins/<name>/
plugin.yaml # Required manifest
initialize.py # Optional one-time setup script
hooks.py # Optional framework runtime hook functions
default_config.yaml # Optional default settings fallback
README.md # Optional, shown in Plugin List UI
LICENSE # Optional, shown in Plugin List UI
@ -195,7 +196,6 @@ save_plugin_config(
```
## Plugin Initialization Script (`initialize.py`)
If your plugin requires one-time setup (e.g., installing dependencies, downloading models), add an `initialize.py` at the plugin root:
```python
@ -220,6 +220,26 @@ if __name__ == "__main__":
Users trigger it via the **Init** button in the Plugin List UI. Return `0` on success, non-zero on failure.
## Runtime Hooks (`hooks.py`)
If your plugin needs framework-internal hook points, add a `hooks.py` file at the plugin root. The framework can call exported functions by name via `helpers.plugins.call_plugin_hook(...)`.
- `hooks.py` runs inside the **Agent Zero framework runtime**, not the separate agent execution environment.
- Use it for things like install hooks, plugin registration work, cache setup, file preparation, or other internal framework operations.
- Hook functions may be sync or async.
- Current example: the plugin installer calls `install()` in `hooks.py` after placing a plugin in `usr/plugins/`.
### Environment targeting rules
- If `hooks.py` runs `sys.executable -m pip install ...`, it installs into the same Python environment that is running Agent Zero.
- That is correct for dependencies needed by the plugin inside the framework runtime.
- If the dependency is meant for the separate agent runtime or for OS-level tools, do **not** assume the current environment is correct.
Instead, explicitly switch targets in a subprocess:
- invoke the exact Python interpreter for the target runtime
- activate the target virtualenv in the subprocess before running `pip`
- run the relevant OS package manager from a subprocess configured for the intended environment
In Docker, this usually means `hooks.py` affects `/opt/venv-a0` unless you intentionally target `/opt/venv` or another environment.
---
## Community Plugin: GitHub Repo + Plugin Index Submission

View file

@ -14,6 +14,13 @@ export function toggle_area(area, enabled) {
enabledAreas.set(area, !!enabled);
}
export function has(area, key) {
if (!isEnabled(area)) return false;
const areaCache = cache.get(area);
if (!areaCache) return false;
return areaCache.has(key);
}
export function add(area, key, data) {
if (!isEnabled(area)) return;
let areaCache = cache.get(area);