Tools
Capability tools — explicit command wrappers, Python @tool functions, async work, errors, and shared state.
Tools are Python functions an agent can call. Dreadnode uses type annotations and Pydantic to generate the schema the model sees, so well-typed function signatures become well-shaped tool calls.
import typing as t
from dreadnode import tool
@tooldef lookup_indicator( indicator: t.Annotated[str, "IP, domain, or hash to investigate"],) -> dict[str, str]: """Look up an indicator in an intel source.""" return {"indicator": indicator, "verdict": "unknown"}The docstring becomes the tool description. typing.Annotated metadata becomes the parameter description. The return type drives serialization.
Before writing a Python tool
Section titled “Before writing a Python tool”Python tools are powerful, but they’re not always the right shape. Most capabilities are best served by teaching a workflow in a skill and letting the agent reach for tools it already has. Before adding @tool, work down this ladder:
- Bash + an existing CLI. If the workflow can be expressed as a shell pipeline against a tool the agent already knows (
rg,jq,gh,kubectl, vendor CLIs), the cheapest capability is a skill that teaches the pipeline. The broad execution tool stays one broad tool; commands passed to it do not become registered tools. - A dedicated command wrapper. Use this when one fixed CLI operation needs its own typed identity, immutable revision, timeout, and future policy reference.
- An MCP server. Reach for MCP when the operation needs a stateful service, multiple related methods, a remote API, or a non-command protocol.
- A Python
@tool. Last fallback. Reach here when the logic is genuinely Python-native — parsing a Pydantic structure, manipulating an in-process object, or glue tighter than spawning a subprocess.
A capability that ships ten thin Python wrappers around CLIs you could have called from bash is a maintenance liability — the wrappers go stale, the schemas drift, and every call still spawns a subprocess underneath. If you do write Python tools, follow the Async tools rule below — blocking sync work in a @tool is the single most common cause of stalled TUI sessions.
Dedicated command wrappers
Section titled “Dedicated command wrappers”Declare a command wrapper when a single CLI operation needs a stable, typed tool identity:
commands: - name: nmap_scan description: Scan one target with nmap input_schema: type: object properties: target: { type: string } required: [target] additionalProperties: false executable: nmap version_probe: [--version] expected_version: 'Nmap version 7.95' argv: [-sV, '{target}'] timeout_seconds: 120 working_directory: workspace adapter_version: 1.0.0Publication creates one logical command Tool and one immutable revision containing this configuration. A new capability version creates a new revision under the same Tool. Republishing identical content preserves IDs; changing the wrapper under the same capability version fails.
Command declarations never run during validation or publication. In a synced runtime, the native agent exposes a wrapper only when the resolved payload includes its exact ToolRevision ID and [executable, *version_probe] exits zero with trimmed stdout equal to expected_version. The runtime repeats that check immediately before every call, so executable drift fails closed.
The adapter validates inputs with Draft 2020-12 JSON Schema and accepts only strings, finite numbers, integers, and booleans. It renders each template entry into one argv value, uses the declared workspace or capability directory, passes an empty environment, and never invokes a shell. A timeout kills the whole process group; execution telemetry excludes arguments and process output.
Read the Tool Registry
Section titled “Read the Tool Registry”List the logical tools visible to an organization with an API key that grants capabilities:read:
curl --fail-with-body \ --header "X-API-Key: $DREADNODE_API_KEY" \ "$DREADNODE_SERVER/api/v1/tools?org=$DREADNODE_ORGANIZATION&page=1&limit=50"Each list entry includes the stable tool ID, source, namespace, name, and latest visible revision. A revision includes its ID, source capability artifact ID, version, path, description, nullable input_schema, and nullable input_schema_fingerprint. Python tool builds generate this Draft 2020-12 JSON Schema from the function signature. Fetch the complete visible revision history with GET /api/v1/tools/{tool_id} using the same optional org query parameter.
input_schema_fingerprint is the lowercase SHA-256 digest of compact UTF-8 JSON with object keys sorted recursively. Reordering object keys preserves the fingerprint; changing a schema value changes it. Historical revisions published without a fingerprint return null.
An organization- or workspace-scoped API key uses its organization scope. If the request also supplies org, it must match that scope. JWTs and unscoped API keys accept an organization key or UUID when the caller has an active membership. Without org, the API infers a sole active organization membership; callers with multiple memberships must supply org. Platform administrators can bypass membership only when they select an organization explicitly.
The selected organization sees every revision of its own tools. It sees another organization’s tool only through revisions backed by public capability artifacts. The API returns the model-facing input schema but hides raw revision metadata, command executables, and argv templates.
Where tools live
Section titled “Where tools live”Capability tools come from Python files declared in the manifest:
tools: - tools/intel.pyIf tools: is omitted, the runtime auto-discovers any *.py in the tools/ directory. Set tools: [] to disable entirely.
The loader collects from each file:
- module-level
@tool-decorated functions - module-level
Toolinstances - module-level
Toolsetinstances Toolsetsubclasses that construct with no arguments
A file in tools/ that contributes none of these — a shared helper module, or one whose @tool import is wrong — records a degraded component-health entry naming the file rather than being skipped silently. To keep helpers inside tools/ without the warning, prefix the file or its directory with an underscore (tools/_helpers.py, tools/_internal/http.py); anything else belongs outside tools/.
Dependencies
Section titled “Dependencies”A tool’s third-party imports must be declared under dependencies.python.
Local runs use whatever is already installed in your environment, so an undeclared import resolves fine. A sandbox — including the one an evaluation runs in — installs only what the manifest declares. An undeclared import raises ImportError at load, and because component failures are non-fatal the rest of the capability loads without it: the agent runs, that tool is simply missing.
tools: - tools/intel.py
dependencies: python: - httpx>=0.27Async tools
Section titled “Async tools”Define a tool as async def and the runtime awaits the call automatically. No additional decorator argument needed.
import httpximport typing as t
from dreadnode import tool
@toolasync def fetch_indicator( indicator: t.Annotated[str, "Indicator to look up"],) -> dict[str, str]: """Fetch indicator metadata from the intel API.""" async with httpx.AsyncClient() as client: response = await client.get(f"https://intel.example.com/{indicator}") response.raise_for_status() return response.json()Use async def whenever the tool does I/O — network calls, subprocesses, database queries, large file reads, anything that waits on the kernel. Sync @tool functions are reserved for pure-CPU work that returns in well under a second.
If you need to call a subprocess, use asyncio.create_subprocess_exec (see dreadnode.tools.execute for a worked example), not the standard-library blocking variants:
# Don't — blocks the agent runtime for the duration of the subprocess.@tooldef scan(target: str) -> str: result = subprocess.run(["nmap", target], capture_output=True, text=True, timeout=600) return result.stdout
# Do — yields back to the event loop while waiting on the child.@toolasync def scan(target: str) -> str: proc = await asyncio.create_subprocess_exec( "nmap", target, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=600) return stdout.decode(errors="replace")The runtime offloads sync tools to a worker thread, so a blocking sync @tool won’t deadlock the agent — but it still gives up one of the thread pool’s slots, can’t be cancelled cleanly, and competes for the GIL with the TUI’s renderer. Async is the supported shape for I/O; the offload is a safety net so a misbehaving third-party tool doesn’t take the whole session down.
Error handling
Section titled “Error handling”By default, @tool catches every exception and surfaces it to the model as a structured error so it can recover. Override the policy with catch:
@tool(catch=[ConnectionError, TimeoutError])def network_lookup(host: str) -> dict[str, str]: """Catch only the listed exceptions; everything else aborts the turn.""" ...
@tool(catch=False)def must_succeed(name: str) -> dict[str, str]: """Propagate everything — turn fails if this raises.""" ...When the runtime catches an exception, the tool result becomes an ErrorModel carrying the exception type and message. The agent sees enough to retry or change approach.
Truncating output
Section titled “Truncating output”Long tool outputs eat context. truncate caps the serialized return value:
@tool(truncate=4000)def list_files(path: str) -> str: """Returns at most 4000 characters of output.""" ...Truncation happens after serialization, before the result is handed to the model.
Automatic output offload
Section titled “Automatic output offload”Even with truncate unset, the runtime guards against runaway tool output. When a serialized return value exceeds 30,000 characters, the agent loop writes the full content to ~/.dreadnode/tool-output/<YYYYMMDD-HHMMSS>-<tool-call-id>.txt (or whatever configure(cache=...) resolves to) and replaces the in-context result with a middle-out summary — the first 15K characters, a [... N lines truncated — full output saved to <absolute-path>] ... marker, then the last 15K. The agent sees the absolute path and can read the file with the standard file-read tool. Span metadata records only the cache-relative path (e.g. tool-output/<file>.txt) so the platform never receives absolute filesystem paths.
This is automatic; tools don’t need to opt in. Set truncate= explicitly when you want a tighter cap or know the model never needs the long-tail content.
Stateful toolsets
Section titled “Stateful toolsets”Use Toolset when a group of tools shares state — an HTTP session, a cache, a client:
import typing as t
import dreadnode
class IntelTools(dreadnode.Toolset): def __init__(self) -> None: self.cache: dict[str, str] = {}
@dreadnode.tool_method def lookup( self, indicator: t.Annotated[str, "Indicator to investigate"], ) -> dict[str, str]: """Look up an indicator.""" if indicator in self.cache: return {"indicator": indicator, "verdict": self.cache[indicator]} verdict = "unknown" self.cache[indicator] = verdict return {"indicator": indicator, "verdict": verdict}Every method decorated with @dreadnode.tool_method becomes a tool. The instance is constructed once per capability load — state lives for the runtime’s lifetime.
@tool_method accepts the same catch and truncate arguments as @tool.
Toolset subclasses must construct with no arguments — the loader calls MyToolset() directly and skips any class that raises TypeError. Take constructor parameters and your Toolset will be silently dropped from the capability.
Async resources in toolsets
Section titled “Async resources in toolsets”The loader instantiates Toolset subclasses synchronously and never enters an async context. So if your tools need an async resource (an httpx.AsyncClient, a database connection pool, a long-lived MCP client), construct it lazily on first use — not in __init__:
import httpximport typing as tfrom pydantic import PrivateAttr
import dreadnode
class HttpTools(dreadnode.Toolset): _client: httpx.AsyncClient | None = PrivateAttr(default=None)
def _ensure_client(self) -> httpx.AsyncClient: if self._client is None: self._client = httpx.AsyncClient(timeout=30) return self._client
@dreadnode.tool_method async def fetch( self, url: t.Annotated[str, "URL to fetch"], ) -> str: """Fetch a URL and return the body.""" response = await self._ensure_client().get(url) response.raise_for_status() return response.textUse PrivateAttr for runtime-only state — Pydantic skips it during validation, which keeps the toolset constructible with no args.
Reference
Section titled “Reference”The full @tool, Tool, and Toolset API — including Component, Context injection, and serialization details — lives at dreadnode.tools.