A tool has two parts: a Python handler that performs an operation, and a registered name, description, input schema, and execution policy that the agent uses to call it. Registration makes a handler available; it does not execute it.

Choose a registration style

Style Example Best use
Constructor function Agent(tools=[fetch_url]) A small, explicit list of handlers
Constructor built-in name Agent(tools=["fetch_url"]) Load a shipped tool without importing its handler
Constructor bundle Agent(tools=["web"]) Load a built-in group
Later registration agent.tools.register_tool(fetch_url) Assemble tools after creating the agent
Decorator @agent.tools.register(...) Define a custom handler beside its registration
Built-in helper register_web_tools(agent.tools, ...) Configure and select tools in a bundle
Registry loading registry.load_builtin("web", ...) The same built-in helper options through one entry point
Tool definition Agent(tools=[definition]) Reuse an explicit schema and execution policy

All of these inherit the agent's permission policy when permission is omitted. A normal Agent() defaults to ALLOW. A custom manager can instead default to ASK or DENY. A preconfigured ToolDefinition retains its explicit permission. Registration style does not choose the permission default.

Prefer functions for simple registration and helpers for configured capabilities. Bundle names currently include filesystem, bash, web, memory, and all; a bundle can contain more tools than one standalone function. The Skill tool and the knowledge tools (search_concepts, read_concept, get_neighbors) are registered by Agent(skills=...) and Agent(knowledge=...), not by a bundle name; see Skills and Knowledge (OKF).

Constructor registration and registration afterward

from harnessx import Agent
from harnessx.builtin.web import fetch_url

agent = Agent(tools=[fetch_url])

The constructor infers the tool name, schema, and description from the function. You can also register afterward, before starting the run:

from harnessx import Agent
from harnessx.builtin.web import fetch_url

agent = Agent()
agent.tools.register_tool(fetch_url)

Both expose fetch_url to subsequent model calls. Finish changing bindings before starting a run; mutating a registry during execution can invalidate an in-flight call. Use async with agent: or await agent.aclose() to close an agent when done.

A constructor list may mix functions, built-in names, and ToolDefinition objects. Passing an existing ToolRegistry attaches that same registry to an ordinary agent; it does not clone the registry. Declare subagent tools separately: each specialist task receives a fresh registry with copied schemas and metadata.

Direct function versus a registration helper

These imports serve different purposes:

from harnessx.builtin.web import fetch_url           # operation
from harnessx.builtin.web import register_web_tools  # registration/configuration

fetch_url is an async Python function. register_web_tools installs the web bundle into a supplied registry and returns the list of registered names. The web bundle currently contains fetch_url.

from harnessx import Agent
from harnessx.builtin.web import register_web_tools

agent = Agent()
registered = register_web_tools(
    agent.tools,
    include=["fetch_url"],
    max_length=20_000,
)
assert registered == ["fetch_url"]

This uses the same HTTP-fetch implementation, with configured response truncation. With a non-default max_length, the helper creates a wrapper whose model input is only url; the model cannot override that configured limit. Direct fetch_url registration exposes its url and optional max_length function parameters. With the helper's default max_length=500_000, it currently registers the direct function, so that parameter remains visible. Truncation limits the returned text, not bytes downloaded from the network.

Both approaches inherit permissions. Configuration can still change the handler and schema; equivalent permissions do not imply identical capabilities.

Do not pass register_web_tools as an agent tool: its argument is a registry, not an input for the model. Call the helper during application setup.

Permission defaults and precedence

Registration Ordinary agent Manager default ASK Manager default DENY
Function, name, bundle, or helper; permission omitted ALLOW ASK DENY
Explicit permission=ALLOW ALLOW ALLOW ALLOW
Explicit permission=ASK ASK ASK ASK
Explicit permission=DENY DENY DENY DENY

A manager override for the tool name takes precedence over every row. Otherwise, an explicit tool permission takes precedence over the manager default. After resolving the effective level, DENY blocks execution even if there is a previous session grant. A session grant can satisfy ASK without another callback.

For example, register an explicit policy or override it later:

from harnessx import Agent, PermissionLevel
from harnessx.builtin.web import fetch_url

agent = Agent()
agent.tools.register_tool(fetch_url, permission=PermissionLevel.ALLOW)
agent.permissions.set_permission("fetch_url", PermissionLevel.DENY)

ASK needs an approval mechanism

A plain agent does not display a prompt automatically. Direct execution and ordinary runs deny ASK calls when no approval callback is provided.

For a terminal application:

from harnessx import Agent, CliPermissionManager
from harnessx.builtin.web import fetch_url

agent = Agent(tools=[fetch_url], permissions=CliPermissionManager())

CliPermissionManager defaults to ASK. That includes built-in tools registered without an explicit policy. Alternatively, supply a synchronous or asynchronous callback. This complete callback example approves only one known URL:

from harnessx import Agent, PermissionLevel, PermissionManager, ToolCall, ToolDefinition
from harnessx.builtin.web import fetch_url

async def approve(call: ToolCall, definition: ToolDefinition) -> bool:
    return call.name == "fetch_url" and call.input.get("url") == "https://example.com"

agent = Agent(
    tools=[fetch_url],
    permissions=PermissionManager(
        default_level=PermissionLevel.ASK,
        approval_callback=approve,
    ),
)

A UI can implement the same callback by awaiting the user's decision. Providing only approval_callback leaves the default at ALLOW; also select ASK for the tools that should request approval. Callbacks must return an actual boolean.

Durable AgentRuntime runs persist ASK requests and return awaiting_input. Use the returned execution key with runtime.approve(...), then resume the run; see the recovery documentation. This persisted approval behavior applies to the outer delegation call, not to the non-durable specialist's internal tools. MCP tools retain their explicitly configured per-server permission policy.

Decorators and schema inference

from harnessx import Agent, PermissionLevel

agent = Agent()

@agent.tools.register(permission=PermissionLevel.ALLOW, replay_policy="safe")
def calculate(a: int, b: int) -> int:
    """Multiply two numbers.

    Args:
        a: First number.
        b: Second number.
    """
    return a * b

The description before an Args: or other recognized section becomes the tool description, including multiple lines. An Args: section documents parameters. Type hints drive JSON Schema: primitive types, nullable unions, Literal, nested lists, and dictionaries with string keys are supported. Nullable parameters remain required unless they have a Python default. Unsupported annotations fail during registration; use register_with_schema for a custom contract. replay_policy accepts ReplayPolicy.SAFE, ReplayPolicy.IDEMPOTENT, ReplayPolicy.MANUAL, or the plain strings; ToolDefinition.replay_policy stores the string.

Sync and async handlers both work. Synchronous handlers run outside the event loop. A timeout stops waiting for a synchronous handler; Python cannot forcibly stop its worker thread or reverse an external effect.

Explicit schemas and reusable definitions

Use a schema when type inference cannot express the input contract:

from harnessx import Agent, ToolRegistry

async def label(value: str) -> str:
    return value.upper()

registry = ToolRegistry()
definition = registry.register_with_schema(
    name="label",
    description="Uppercase a short label.",
    input_schema={
        "type": "object",
        "properties": {"value": {"type": "string", "maxLength": 40}},
        "required": ["value"],
        "additionalProperties": False,
    },
    handler=label,
)
agent = Agent(tools=[definition])

name, description, input_schema, and handler are the positional parameters of register_with_schema; permission, concurrent, replay_policy, timeout_seconds, and replace are keyword-only. Registering a ToolDefinition copies its schema and metadata while retaining the handler. Registration options override only specified metadata; registering an existing definition does not silently reset its permissions or replay policy.

Configured built-in tools

Helpers support include, exclude, permission, and replace, plus options specific to the bundle. Unknown names, filters, and unsupported options fail.

For filesystem inspection, configure a root and only the operations you need:

from pathlib import Path
from harnessx import Agent
from harnessx.builtin import register_filesystem_tools

agent = Agent()
register_filesystem_tools(
    agent.tools,
    include=["read_file", "list_directory"],
    base_path=str(Path.cwd()),
    max_read_bytes=1_000_000,
    max_directory_entries=1_000,
)

The root must exist. Scoped operations reject symlinks in files and parent directories using descriptor-relative, no-follow access on POSIX. This is an application boundary, not an OS sandbox for arbitrary code: hard links, mounts, and other processes relocating directories require stronger isolation. Without base_path, filesystem tools use host filesystem access under process permissions.

Configured filesystem limits are hidden from model input. Existing files require overwrite=True; writes publish atomically. Writes and downloadable-file creation remain non-concurrent when registered through filesystem helpers, regardless of permission. Direct standalone functions use ordinary registration metadata, so choose concurrent=False explicitly if needed.

Download copies use configured output_dir, otherwise AGENT_OUTPUT_DIR or ./output. Keep the output directory application-owned. Filesystem reads and writes run outside the event loop.

Other options include a sandbox for the bash helper and memory stores for the memory helper. Agent(tools=["bash"], sandbox=sandbox) binds a caller-owned sandbox as the bash built-in's engine, so the model's shell commands run inside it; the registry exposes the binding as agent.tools.sandbox. Single-tool loading accepts the corresponding bundle options:

from pathlib import Path
from harnessx import ToolRegistry

registry = ToolRegistry()
registry.load_builtin("read_file", base_path=str(Path.cwd()))

Registry-wide tool policy

AgentConfig(tools=ToolPolicy(...)) carries registry-wide options to the registry the agent adopts, including a plain list of functions:

from harnessx import Agent, AgentConfig, ToolPolicy
from harnessx.builtin.web import fetch_url

agent = Agent(
    config=AgentConfig(tools=ToolPolicy(default_timeout_seconds=60, dedupe_calls=True)),
    tools=[fetch_url],
)
assert agent.tools.policy.default_timeout_seconds == 60

Precedence, highest first: a value given to the ToolRegistry(default_timeout_seconds=, dedupe_calls=) constructor, the agent's config.tools, then the built-in default (DEFAULT_TIMEOUT_SECONDS, 300 seconds, and no deduplication). Tools registered with their own timeout_seconds are never changed. registry.policy shows the effective values. With dedupe_calls=True, identical repeated calls within one run return the first result.

Replacement, timeouts, and replay

Duplicate tool names fail by default. Use replace=True to deliberately replace a binding during setup. Inspect get_tool(name), list_tools(), or get_tool_params(); the last returns canonical provider-neutral tool schemas.

Ordinary function registrations default to concurrent=True, a timeout of DEFAULT_TIMEOUT_SECONDS (300 seconds) unless a ToolPolicy changes the default, and replay_policy="manual". Configure these with register_tool or the decorator. Built-in helpers do not automatically mark fetching or other operations replay-safe.

Durable execution uses manual to stop for recovery when an interrupted operation's outcome is unknown. Select safe only when repeating an operation is safe, or idempotent when the integration actually enforces idempotency. Neither setting provides exactly-once external effects on its own.

Calling a function versus executing an agent tool

await fetch_url(url) is an ordinary Python call. It bypasses the registry's schema validation, permission manager, timeout, and durable execution records.

await registry.execute(call, permissions=agent.permissions) validates the input, checks permission, and applies the registered timeout. Without a manager it uses the ordinary ALLOW default for unspecified policies; explicit ASK without a callback and explicit DENY return the error result "Permission denied". It does not provide durable replay or the agent's complete middleware/event pipeline.

agent.run(...), agent.run_stream(...), and AgentRuntime use the shared engine for model-requested tools. They apply execution checks before dispatch, with persisted approvals and recovery when using a durable runtime. Handler exceptions usually become error tool results; runtime/storage errors may fail the run.