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.