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¶
api_min_versionis declared (whenever recent APIs are used).setup_extensionis idempotent and expensive resources are cached at module level.- Data lives under
ctx.data_dir(), settings are read fromctx.settings, and no absolute paths are hard-coded. - Error paths raise clear exception messages that the
reasonfield of/extensionscan explain. - A
README.mdis provided: purpose, configuration examples, dependencies, minimal usage.
Related documents¶
- Exact loading and failure semantics: Mechanism and Loading
- Every decorator field, one by one: Extension Attributes