Skip to content

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.py triggers 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-.py files (for instance a README.md inside 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 ExtensionAttr and compared with xun_version() while scanning: only the numeric core is compared (1.2rc3 counts as 1.2) and the shorter side is zero-padded.
  • Outside the range the extension is skipped, not failed: setup_extension never 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() return None, 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_extension runs once per agent — once for every sub-agent too. The two internal helper agents (the session summariser and the command risk assessor) set enable_extensions to False, so they replay no extensions.
  • Extensions run before model-name autodetection, so changing config.model.name takes effect.
  • An import failure or an exception raised by setup_extension only prints Extension 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. KeyboardInterrupt and CancelledError are the exception — they propagate and abort initialize().
  • 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 set Agent.config.enable_extensions = False and then call initialize().

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)