Extension system¶
Drop Python files into $XUN_HOME/extensions/ and every agent replays their side effects at initialize() time: registering tools, adding hooks, adding commands, changing configuration. No registry, and no wiring at the call site.
Extensions are trusted code
Extensions run with full process privileges during import and initialisation, exactly like a shell's rc file. Put only code you trust there.
Two forms¶
| Form | Path | Description |
|---|---|---|
| package | extensions/{name}/setup_extension.py |
the entry-file name is fixed; sibling modules in the same directory can be imported relatively |
| flat | extensions/{name}.py |
zero ceremony; relative imports are not supported |
- A directory without
setup_extension.pytriggers a warning and is skipped; when both forms share a name, the package wins and the flat file is shadowed. - Entries starting with
.or__are ignored; non-.pyfiles (for instance aREADME.mdinside a directory) are skipped silently. - Application order is sorted by name, with both forms sorted together, so the result is deterministic.
The entry function¶
The module must provide a callable setup_extension(ctx):
"""Log every tool call.""" # first line of the docstring = the extension description, shown by /extensions
from xun import ExtensionContext
def setup_extension(ctx: ExtensionContext) -> None:
ctx.agent.hooks.before_tool_call.add(lambda args: print(args.tool_calls))
ExtensionContext exposes only two public API members:
| Member | Description |
|---|---|
ctx.agent |
the agent being initialised (Agent[Agent.T.Uninit]) |
ctx.name |
the extension name (directory name or file name) |
Every effect is achieved through ctx.agent: toolbox.register(...), hooks.*.add(...), command.register(...), mutating config in place, state, system(...), display, workspace.
Version compatibility gate¶
An extension can declare the xun API version range it supports on its entry function (both bounds inclusive):
"""Needs a recent xun."""
from xun import ExtensionContext, extension_attr
@extension_attr(api_min_version="1.2", api_max_version="2.0")
def setup_extension(ctx: ExtensionContext) -> None:
...
- The range is attached to the function as an
ExtensionAttrand compared withxun_version()while scanning: only the numeric core is compared (1.2rc3counts as1.2) and the shorter side is zero-padded. - Outside the range the extension is
skipped, notfailed:setup_extensionnever runs, and the reason is warned once at scan time and shown by/extensions. - Running from a source checkout (no package metadata) makes
xun_version()returnNone, which lets everything through.
Loading order¶
flowchart TD
A["Agent.initialize()"] --> B{"extensions enabled?"}
B -->|no| Z["skip"]
B -->|yes| C["scan & import<br/>by name · once per process"]
C --> D{"inside version range?"}
D -->|no| S["skipped: warn"]
D -->|yes| E["run setup_extension"]
E -->|ok| G["loaded"]
E -->|raises| F["failed: warn and continue"]
S --> H["continue initialising"]
G --> H
F --> H
Key semantics:
- Scanning and importing happen once per process and are cached per directory (module-level side effects run once, module state is kept), whereas
setup_extensionruns once per agent — once for every sub-agent too. The two internal helper agents (the session summariser and the command risk assessor) setenable_extensionstoFalse, so they replay no extensions. - Extensions run before model-name autodetection, so changing
config.model.nametakes effect. - An import failure or an exception raised by
setup_extensiononly printsExtension warning:and records the status (failed); it neither blocks start-up nor affects other extensions, but the remaining statements inside that same setup run are aborted by the exception.KeyboardInterruptandCancelledErrorare the exception — they propagate and abortinitialize(). - Loaded modules are never unloaded (sub-agent replay depends on them).
- How to turn it off:
config.enable_extensions = false(changed in the configuration file or inside an extension), or setAgent.config.enable_extensions = Falseand then callinitialize().
Checking extension status¶
Start-up no longer prints a Loaded extensions: summary; /extensions is the only entry point (import and initialisation failures still print Extension warning:). The terminal renders a table, the web UI renders a ShowExtensionsEvent; there are four statuses:
| Status | Meaning |
|---|---|
loaded |
setup_extension completed successfully at least once |
uninitialized |
scanned and imported, but setup has not run |
skipped |
the declared version range excludes the running xun; the reason is in the reason field (merged into the description column in the terminal) |
failed |
the import failed or setup raised; the reason is in the reason field (merged into the description column in the terminal) |
The status values are exactly xun.extension.ExtensionStatus; Extension, ExtensionAttr (fields api_min_version / api_max_version) and ExtensionInfo also live in xun.extension, while the package root exports only ExtensionContext and extension_attr.
Loader API¶
Agent.extension_loader defaults to the process-wide default_loader (directory {XUN_HOME}/extensions/), and sub-agents share the same loader by reference. To use another directory or discovery rule, replace it before calling initialize():
from pathlib import Path
from xun.extension import ExtensionLoader
agent.extension_loader = ExtensionLoader(get_extension_dir=lambda: Path("plugins"))
| Member | Description |
|---|---|
scan() |
one Result[Extension, ExtensionIssue] per candidate, sorted by name; cached per directory |
imported() |
the extensions that imported successfully |
infos() |
an ExtensionInfo for every discovered extension (name, description, path, status, reason) — exactly what /extensions shows |
clear_scan_cache() |
makes the next scan() re-import, keeping the recorded statuses |
apply(agent) |
runs every setup against an uninitialized agent; called from initialize() |
Examples in the repository¶
The repository's extensions/ directory currently holds web_display_log.py and z_search.py, both in the flat form; below are their skeletons with comments and details stripped out.
"""Console log for web display."""
from xun import ExtensionContext, WebDisplay
def setup_extension(ctx: ExtensionContext) -> None:
if not isinstance(ctx.agent.display, WebDisplay):
return
agent_id = f"{ctx.agent.name} ({ctx.agent.identifier})"
ctx.agent.hooks.before_display_error.add(
lambda args: print(f"{agent_id} Error: {args.message}")
)
Checking the display-layer type before adding the hook is the usual way to avoid side effects on unrelated agents.
"""Override web_search with an external Web Search tool."""
from xun import ExtensionContext, tool_attr
try:
from some_sdk import Client
_client = Client()
except Exception as exc: # the optional dependency or the key is missing
_client = None
print(f"extension disabled: {exc}")
def setup_extension(ctx: ExtensionContext) -> None:
if _client is None:
return # fails silently; raising instead would be recorded as failed
@tool_attr(name="web_search", override=True)
def my_search(query: str, limit: int = 5) -> list[dict]:
"""Search the web."""
return _client.search(query, count=limit)
ctx.agent.toolbox.register(my_search)
override=True lets a tool of the same name silently replace the built-in implementation; the probe for optional dependencies stays at module level and setup only checks availability — return fails silently, while raise is recorded as failed and prints a warning. To use the package form, move the example file into {name}/setup_extension.py; everything else stays the same.
Relationship to other mechanisms¶
| What you want to do | Better mechanism |
|---|---|
| add things to one specific agent | call agent.toolbox.register() / agent.hooks.*.add() directly |
| add things to every agent, shared across projects | extensions (this page) |
| only change configuration | the configuration file; or change ctx.agent.config inside an extension |
| replace the system prompt | agent.system(), or setup_agent(default_system_prompt=False) |