Skip to content

Custom Tools

Capability tools in v1 come from Python files exported by a capability manifest. They are not declared inline in capability.yaml, and there is no separate shell-tool runtime in the current contract.

Tools are structured functions that an LLM can call. The SDK uses Python type annotations and Pydantic models to validate inputs and serialize parameters for model providers.

Add Python files to the manifest:

schema: 1
name: threat-hunting
version: '0.1.0'
description: Threat hunting tools.
tools:
- tools/intel.py

If tools is omitted, the runtime auto-discovers Python files under tools/.

Use @dreadnode.tool for simple stateless tools:

import typing as t
import dreadnode
@dreadnode.tool
def 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",
}

If you want full control without the decorator, use Tool.from_callable() directly.

from dreadnode.agents.tools import Tool
def add(x: int, y: int) -> int:
return x + y
add_tool = Tool.from_callable(add, name="add", description="Add two numbers.")

Type hints drive the parameter schema. Use typing.Annotated to add short parameter descriptions.

Use dreadnode.Toolset when you want a grouped or stateful set of tools:

import typing as t
import dreadnode
class IntelTools(dreadnode.Toolset):
@dreadnode.tool_method
def lookup(
self,
indicator: t.Annotated[str, "Indicator to investigate"],
) -> dict[str, str]:
"""Look up an indicator."""
return {"indicator": indicator, "verdict": "unknown"}

The loader collects:

  • module-level Tool instances
  • module-level Toolset instances
  • Toolset subclasses that can be constructed without arguments

Capabilities ship tool definitions in capability.yaml. Use wrap_tool and wrap_capability to convert capability tool defs into SDK Tool instances that can be merged into your tool map.

import asyncio
from dreadnode.capabilities import load_capability, wrap_capability, wrap_tool
async def main() -> None:
loaded = await load_capability("./capabilities/threat-hunting")
wrapped = wrap_capability(loaded)
first_tool = loaded.manifest.tools[0] if loaded.manifest.tools else None
if first_tool:
single = wrap_tool(first_tool, loaded)
print("Wrapped tool:", single.name)
print(f"Wrapped {len(wrapped.tools)} tools from {wrapped.name}.")
asyncio.run(main())

Use MCP instead when the implementation is:

  • a shell command
  • a Node or Go service
  • a remote API integration
  • a third-party tool you want to run out of process

That keeps capability tooling split into two paths:

  • Python-native logic via @dreadnode.tool and dreadnode.Toolset
  • everything else via MCP