Skip to content

Tool Setup

A tool is an ordinary Python function: the signature supplies the argument structure, the docstring supplies the description, and the framework generates the schema sent to the model from both. This page documents the interface for tool registration and metadata; the inventory of built-in tools is a reference only, not part of the interface.

A function is a tool

from xun import ToolBox

def multiply(a: int, b: int) -> int:
    """Multiply two integers."""
    return a * b

toolbox = ToolBox().register(multiply)     # returns itself, chainable
# can also be used as a decorator: @toolbox.tool
Aspect Rule
Tool name Defaults to the function name; tool_attr(name=…) can change it
Description The entire docstring (surrounding whitespace stripped) — state the constraints and the return format clearly, do not rely on truncation
Parameters Built into a pydantic argument model from the type annotations, extra="forbid", validated with strict=True on each call
Unannotated parameter Takes the type of its default value if there is one, otherwise degrades to Any — so always write annotations
Return value Must be JSON-serializable (str / int / float / bool / None / dict / list); a None return is converted to ""
Exceptions Always wrapped by except_safe into Result.Err(ErrorInfo): the traceback goes to the developer, the model receives str(exception) — so write exception messages for the model to read
Synchronous execution Tools are synchronous functions; an async function returns a coroutine object that cannot be serialized. Make long tasks concurrent or background them
flowchart LR
    M["Model"] -->|"tool call JSON"| V["Argument model validation<br/>strict + forbid"]
    V --> I["Inject ToolCallContext"]
    I --> F["Your function"]
    F --> R["Result.Ok / Err"]
    R --> C["Conversation history + events"]

tool_attr

from xun import tool_attr

@tool_attr(name="MultiplyTool")
def multiply(a: int, b: int) -> int:
    """Multiply two integers."""
    return a * b
Parameter Default Effect
name None The external tool name; the function's own name stays the same
required_capabilities [] Model capabilities the tool needs (currently only "vision"); when the model's capabilities do not include one, the tool is kept out of the tool list
override False Allows replacing an existing tool of the same name

Name-conflict rule: by default a duplicate name raises ValueError: Conflict tool name: x; replacement happens only when the new tool carries override=True, and an existing override tool is never overwritten by a non-override one either. Runtime policies such as confirmation, sandboxing and concurrency are not part of tool_attr: they are implemented by the tools themselves, through the display layer or a policy object — see below and Display Extensions.

Tool execution context

The function itself holds no state. When it needs context, declare a ToolCallContext parameter and the framework recognizes it from the annotation and injects it — that parameter never appears in the schema sent to the model.

from xun import ToolCallContext as Context
from pathlib import Path

def workdir_name(ctx: Context[dict]):
    ctx.value["tool_call_time"] = "12:00:00"
    return Path(ctx.agent.workspace.workdir).name

ans = setup_agent(tools=[workdir_name], display=NullDisplay()) \
        .instruct("What is the name of your working directory?").execute(context={"tool_call_time": "?"})
# once ans returns, the context dict has been rewritten by the tool
Member Description
ctx.agent The Agent[Init] executing this tool — workspace, config, display, hooks, state and cancel_event all live on it
ctx.tool_name The name of the tool that was actually called
ctx.value The arbitrary object passed via execute(context=…); changes made inside the tool are visible to the caller

Recognition rule: matching is by annotation (ToolCallContext or ToolCallContext[T]), independent of the parameter name, and exactly one is injected per function.

The pass-throughs most often used inside a tool:

ctx.agent.workspace.workdir                     # working directory
ctx.agent.workspace.resolve(p)                  # path resolution + scope check
ctx.agent.get_confirm("Confirm overwrite?")     # ask a human through the display layer
ctx.agent.check_cancel()                        # cancellation checkpoint inside long loops
ctx.agent.display_event(HTMLInfoEvent(html=…))  # push custom content to the interface

ToolBox interface

Method Description
register(*funcs) Registers plain functions (automatically wrapped in except_safe), returns self
tool(func) register in decorator form
register_raw(*functions) Registers prebuilt Function objects, which may carry a ready-made JSON Schema (for dynamic tools)
pop(name) Removes a tool
disable(*names) / enable(*names) Disables / enables by name or glob; query with is_disabled(name)
list_tools(capabilities=None, include_disabled=False) List of tool definitions, filtered by capability
list_tools_json(capabilities=None) The OpenAI tools array
with_defaults(*sets) Loads built-in tool sets; see the table below
with_subagent_provider(getter=None) Registers sub-agent tools; see Sub-agent Derivation
disable_subagent() / enable_subagent() Turns the derivation tools off / on (used to bound recursion depth)
clone() Shallow copy, used by Agent.inherit

A disabled tool is left out of the schema and also fails if it is called.

Built-in tool sets

Called without arguments, with_defaults() loads all eight sets, 33 tools in total; you can also load only some of them: toolbox.with_defaults("fs", "cmd").

Set Tools
system system_info, datetime, sleep
framework ask_preference, extract_compacted_tool_result
fs list_dir, read_file, file_info, temp_dir, write_file, mkdir, move, copy, delete, request_image, glob, grep
patch apply_patch
cmd bash, bash_spawn, bash_wait, bash_running, bash_kill
search web_search
browser browser_page, browser_resize, browser_snapshot, browser_interact, browser_evaluate, browser_logs, browser_screenshot
diagnostic check_syntax, check_lint

With setup_agent(default_tools=True) or with_subagent_provider(), four more derivation tools are added (agent_run / agent_spawn / agent_wait / agent_list).

Scope and dependencies

Paths in the filesystem tools are resolved relative to the working directory, and anything outside the scope is rejected; write operations require confirmation under the rules below. browser needs playwright install; apply_patch needs Git installed; check_lint needs mypy.

Paths and write permission

Workspace.resolve() classifies a path as either inside the working directory or inside the temporary directory, and raises outright for anything else. Write permission applies only to operations that destroy existing content:

Case Behavior
New file / new directory Allowed outright
Overwrite, move, copy overwrite, delete inside the temporary directory Allowed outright
Matches the write allowlist Allowed, no prompt
Everything else: overwriting an existing file, moving a source file, copying into a directory that already has that name, deleting, patching an existing file Prompt: authorize the path / allow this once / deny

Only a user choice of "authorize this path" writes to the allowlist — an auto-confirmation pass leaves no persistent grant. The allowlist is runtime state of the current agent (stored in agent.state and cleared when the process restarts); view and edit it with /policy path_allowlist*.

Risk assessment for built-in commands

The bash tool first parses the command structurally with shlex, splits it into command segments on ; && || |, and judges each segment. If even one segment matches a reason below, the command must go through confirmation:

Reason Details
Command segment not on the allowlist The whole segment must match (multi-word prefixes included)
Uses a shell operator that needs confirmation Redirections and background execution (>, >>, <, &, …); safe forms such as >/dev/null and 2>&1 are exempt
Path-style command Written as ./bin/tool or /usr/bin/tool rather than a bare command name
Command assembled from backticks or newlines `…` and multi-line commands
$(…) command substitution The command inside the parentheses is judged recursively

When confirmation is required, one risk-assessment sub-agent decides first (read-only tools, at most 3 tool calls, no extensions loaded):

Verdict Effect
allow Executes directly, without disturbing the user
unsure Shows a confirmation prompt whose default is "allow"
reject Shows a confirmation prompt whose default is "deny"; fails immediately in auto-confirmation mode

In the confirmation prompt you can pick "Other" and type a refusal reason; both the reason and the risk verdict are returned to the agent. The title reflects the verdict: "Command Confirmation" versus "Dangerous Command Confirmation".

The default allowlist contains only read-only and system-information commands (ls, cat, head, tail, grep, nl, stat, df, du, ps, top, uname, dmesg, journalctl, nvidia-smi, ping, ss, …), plus python -m unittest / python -m pytest and git status / log / diff / show. Entries containing spaces match as a word-boundary prefix, so git diff --cached matches git diff. Commands such as curl, wget and sed, which can both reach the network and modify files, are not on the default list — go through confirmation or add them yourself:

/policy cmd_allowlist_add "python -m ruff"

Best effort, not a sandbox

The module comment of cmd_policy.py says it plainly: shell execution can barely be made safe by parsing alone, so these judgements are best effort. When you truly need isolation, use the xunc container.

Background commands

Start long-running commands with bash_spawn: it returns a task ID and two log file paths (stdout / stderr), which you then manage with bash_wait (an optional polling timeout), bash_running and bash_kill; the results also carry the duration and the output size. The task table is stored in the agent's state, so derived sub-agents share the same tasks, and the parent agent cleans them up when it finalizes.