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.
Loading order¶
flowchart TD
A["Agent.initialize()"] --> B{"extensions enabled?"}
B -->|no| Z["skip"]
B -->|yes| C["scan extensions by name"]
C --> D["import the module"]
D --> E["run setup_extension"]
E --> F["continue initialising"]
Key semantics:
- A module is imported only once per process (module-level side effects run once, module state is kept), whereas
setup_extensionruns once per agent — including sub-agents and internal helper agents such as the summariser or the command risk assessor. - Extensions run before model-name autodetection, so changing
config.model.nametakes effect. - An import failure or an exception raised by
setup_extensiononly printsExtension warning:; it neither blocks start-up nor affects other extensions, but the remaining statements inside that same setup run are aborted by the exception. - 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().
Viewing loaded extensions¶
>>> /extensions
shown: Show me.
web_display_log: Console log for web display
Examples in the repository¶
"""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: # if the dependency or the key is missing, degrade to doing nothing at all
_client = None
print(f"extension disabled: {exc}")
def setup_extension(ctx: ExtensionContext) -> None:
if _client is None:
return
@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; keep the probe for optional dependencies at module level and only check availability inside setup.
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) |