Skip to content

Hooks and session commands

Hooks

Hooks is a group of HookRegistry objects, registered as agent.hooks.<name>:

from xun import Agent, HookArgs

def log_tool_call(args: HookArgs.BeforeToolCallArgs) -> None:
    for call in args.tool_calls:
        print(call.function.name, call.function.arguments)

agent = Agent()
agent.hooks.before_tool_call.add(log_tool_call)

Registry behaviour:

Method Semantics
add(fn) persistent callback, executed in registration order
add_once(fn) automatically removed after the next invoke
invoke(args) calls every callback in turn, then clears the one-shot callbacks

The return values of callbacks are discarded; to change behaviour, modify the argument object in place. Callbacks are wrapped in except_safe as well: ordinary exceptions are swallowed into a Result.Err (execution is not interrupted), and only KeyboardInterrupt / CancelledError propagate up.

Hook list

Hook Arguments (main fields) When / what can be changed
run_start agent when the agent moves from idle into a run (not re-triggered by nested scopes)
run_end agent the outermost scope that actually started the run exits, whether it succeeded, raised an error or was cancelled
before_execution agent, schema, max_iterations, context_value, takeover_result before the execution loop starts, every field can be changed; takeover_result takes over the whole loop, see below
before_execution_step agent before each model call; the auto-compactor is attached here
after_execution_step agent tool results have been written back to the session, before the next model call
before_tool_call agent, tool_calls (a list) before the tools run; tool calls can be added, removed or changed
after_tool_call agent, tool_results (a list of (id, Result)) when the turn has tool calls, after all tools have run and before the results are written back to the session; editing tool_results only affects what is written into the session, the display events for the results have already been emitted
model_text_delta agent, model_call_id, content before a text delta is committed; content can be rewritten
model_reasoning_delta same as above before a reasoning delta is committed
before_display_info agent, message before agent.info() prints; message can be rewritten
before_display_warning same as above before agent.warning() prints
before_display_error same as above before agent.error() prints
after_initialize agent the last step of initialize() (extensions, model probing and display binding are all done)
before_finalize agent at the start of finalize(), a good place to release resources
before_command agent, command, arguments (a list) after the command was parsed successfully, before it runs; arguments can be changed in place; unknown commands do not trigger it
after_command same as above the command finished (including errors that were handled)

Taking over the execution loop

The before_execution argument carries a takeover_result (default None). Once a hook sets it to a string, that execute() call is taken over by the hook:

def canned(args: HookArgs.BeforeExecutionArgs) -> None:
    if cached := args.agent.state.get("cached_answer"):
        args.takeover_result = cached          # becomes the result of execute()

agent.hooks.before_execution.add(canned)

The effect is that the whole loop is skipped: no model call, no hooks or events from inside the loop, and nothing appended to the conversation history. The string becomes the return value of execute(); when execute(schema=...) was given a schema it is still validated against it, and a validation failure fails the run. Useful for result caches, offline replay and test doubles.

Common uses: mirror tool calls into a log, prefix the output, set max_iterations centrally in before_execution or answer from a cache, close external resources in before_finalize (this is how the browser runtime is cleaned up).

Session commands

Commands are the entry point for human interaction (the terminal REPL, the / input box of the web UI). Agent.execute_command() is the single entry point: it parses the arguments with shlex.split, triggers before_command, runs the command, and finally triggers after_command.

from xun import Agent, Command, CommandRegistry

def wordcount(agent: Agent, args: list[str]) -> None:
    """Count words in the given text."""
    agent.info(f"{len(' '.join(args).split())} words")

agent.command.register(Command("wordcount", wordcount))
agent.command.register(Command.from_function(wordcount))     # the name is taken from the function name
agent.execute_command("wordcount", "one two three")

Registration rules:

Point Details
Handler arguments 1 argument → handler(agent); 2 arguments → handler(agent, arguments); otherwise TypeError
Description taken from the first line of the docstring by default; a multi-line docstring becomes the long help for -h
Command groups pass a CommandRegistry as the handler to make it a command group, cmd sub -h then works automatically
Help help is shown when the arguments are exactly -h or --help (cmd sub extra -h passes -h on to the command as an ordinary argument); help is a virtual command and is always available
Errors exceptions inside a command are caught and reported via agent.error(...); KeyboardInterrupt / CancelledError propagate up
Escaping input starting with / is treated as a command; input starting with \/ is an ordinary message

For the list of built-in commands and their descriptions see xun · Terminal session; the policy subcommand group (policy ...) is registered when default_tools=True.

Hooks do not reach the sub-agent; commands do

Agent.inherit() does not inherit hooks, but it does copy command; a sub-agent replays the extensions during initialize(), which registers the hooks again.

Persisting the session history

/save and /load are backed by Store:

from xun.store import Store

store = Store()                       # the default root directory is get_home_dir()
target = store.next_history_store()   # .../conversation/000007.conversation
latest = store.latest_history_store()
one = store.get_history_store(3)        # an int is zero-padded: .../conversation/000003.conversation
two = store.get_history_store("000003") # a string is used verbatim and must match the directory name

Zero-pad the index in /load

/load treats its argument as a string, so /load 000003 resolves while /load 3 reports History 3 not found.; use /load latest when you are unsure of the number.

The actual file is {XUN_HOME}/conversation/{NNNNNN}.conversation/conversation.json, whose keys include conversation_id, time, tokens_used, messages, compacted_toolcalls and compaction.