Skip to content

Extension Quick Start

1. Create the file

A single-file extension is one Python file:

mkdir -p .xun/extensions
from xun import ExtensionContext, extension_attr

@extension_attr(api_min_version="1.4.0")
def setup_extension(ctx: ExtensionContext) -> None:
    def greet(name: str) -> str:
        """Greet someone by name."""
        return f"Hello, {name}!"

    ctx.agent.toolbox.register(greet)
xun
/tools          # greet should show up

Go one step further and add a session command:

from xun import Command, ExtensionContext, extension_attr

@extension_attr(api_min_version="1.4.0")
def setup_extension(ctx: ExtensionContext) -> None:
    def hello(agent, args: list[str]) -> None:
        """Say hello to the given name."""
        agent.info(f"Hello, {' '.join(args) or 'world'}!")

    ctx.agent.command.register(Command.from_function(hello))
/hello Xun

2. Troubleshooting

Symptom Where to look first
Command / tool does not appear Check the status with /extensions: no row at all means it was never discovered (directory, file name, whether setup_extension exists)
Status skipped The version gate did not pass; reason states the required version range
Status failed Import or setup raised an exception; the terminal carries an extension '<name>' setup failed warning
Code changes have no effect Scans are cached per directory; restart the process or call clear_scan_cache()
A sub-agent is missing half the features Most likely a duplicate tool name aborted the rest of setup; make the registration idempotent

A failed extension import does not block startup, so in practice all that remains is one warning buried in the log — running /extensions first is the cheapest move.

3. What you can do inside an extension

ExtensionContext does not offer much by itself; the real capabilities live on ctx.agent:

Goal How
Add an external capability ctx.agent.toolbox.register(fn) registers a plain function; for dynamic sources (MCP, for example) build the schema on the fly with register_raw(Function(...))
Turn a tool off ctx.agent.toolbox.pop(name) / disable("fs_*")
Add a session command ctx.agent.command.register(Command(name=..., description=..., handler=...))
Observe / intervene in execution ctx.agent.hooks.<hook>.add(fn), see Hooks
Add a section to the system prompt ctx.agent.conversation.set_system_persistent_section("skills", "...") (same name overwrites, protected during compaction)
Keep the extension's own state ctx.agent.state["my_flag"] = JsonEntry(...) or RuntimeEntry(...)
Push content to the UI ctx.agent.display_event(HTMLInfoEvent(html=...)), see Display Extensions
Read extension settings ctx.settings, i.e. config.extension_settings[<extension name>]
Read and write extension data ctx.data_dir() returns a private directory, already layered by data_v_<version>

setup is replayed

Every agent (including derived sub-agents) runs setup_extension once. Cache anything expensive or one-shot (server connections, creating a database, creating directories) in module-level variables, and make the registration code tolerant of repeated execution.

4. Learn the structure from the built-in extensions

The three extensions under extensions/ are three typical shapes:

Extension Size What it demonstrates
stat ~1000 lines Four hooks writing to SQLite, /stat queries, xune stat managing budgets, data_version used together with cli
mcp ~1000 lines An async protocol on a synchronous API: asyncio on a background thread; register_raw + pop + is_disabled for per-connection visibility
skills ~750 lines A persistent system-prompt section plus progressive disclosure: list names only at first, read the full text when used; both a tool and a command entry point

The skeleton of stat shows the pattern “extension = hooks + commands” best:

@extension_attr(api_min_version="1.4.0", data_version=DATA_VERSION, cli=(build_parser(), cli.run))
def setup_extension(ctx: ExtensionContext) -> None:
    db_path = init_db(ctx.data_dir() / "stat.db")        # create the database here, not at import time

    def log_tool_calls(args: HookArgs.BeforeToolCallArgs) -> None: ...
    def log_token_usage(args: HookArgs.TokenMetrics) -> None: ...
    def check_budget(args: HookArgs.BeforeExecutionArgs) -> None: ...
    def stat_command(agent, args: list[str]) -> None: ...

    ctx.agent.hooks.before_tool_call.add(log_tool_calls)
    ctx.agent.hooks.completion_token_update.add(log_token_usage)
    ctx.agent.hooks.before_execution.add(check_budget)
    ctx.agent.command.register(Command.from_function(stat_command))

When the budget is exceeded, check_budget simply raises — that is the standard way for an extension to stop a run (ordinary exceptions are swallowed by hooks; to actually block execution, raise CancelledError, see Hooks).

5. Pre-release self-check

  1. api_min_version is declared (whenever recent APIs are used).
  2. setup_extension is idempotent and expensive resources are cached at module level.
  3. Data lives under ctx.data_dir(), settings are read from ctx.settings, and no absolute paths are hard-coded.
  4. Error paths raise clear exception messages that the reason field of /extensions can explain.
  5. A README.md is provided: purpose, configuration examples, dependencies, minimal usage.