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.

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_extension runs 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.name takes effect.
  • An import failure or an exception raised by setup_extension only prints Extension 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 set Agent.config.enable_extensions = False and then call initialize().

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)