Custom tools
Give your agent something only your company can do.
In short: a tool is a thing your agent can do — look up an order, issue a credit, create a ticket. agentFast ships 54 of them, but the useful ones are the ones that talk to your systems. This page is how you write those.
A tool in full
Here's a complete, working tool. Drop it in tools/billing.py in your project:
from core.tools import ToolContext, ToolError, tool
@tool(
"Look up a customer's current subscription: plan, seats, renewal date and "
"outstanding balance. Use this before answering any billing question.",
group="billing",
rate_limit="60/min",
)
async def subscription_lookup(tctx: ToolContext, customer_id: str) -> dict:
if not customer_id.startswith("cust_"):
raise ToolError("INVALID_INPUT", "customer_id must look like cust_…")
billing = tctx.service("billing")
sub = await billing.get_subscription(customer_id)
if sub is None:
raise ToolError("NOT_FOUND", f"no subscription for {customer_id}")
return {
"plan": sub.plan,
"seats": sub.seats,
"renews_at": sub.renews_at,
"balance_usd": sub.balance_usd,
}
That's the whole thing. Four parts are doing work:
The model reads it to decide when to call this tool. "Looks up a subscription" is a bad description; the one above tells the model what it gets back and when to reach for it.
This is the single highest-leverage line in the file. Most tools that "never get called" have a vague description, not a code problem.
customer_id: str is enough. agentFast derives the input schema from your function signature —
there's no separate schema to write, and no way for the two to drift apart.
Defaults work as you'd expect: top_k: int = 5 becomes an optional parameter.
ToolError takes one of twelve fixed codes. The model sees the code
and can reason about it — it knows what to do about NOT_FOUND in a way it doesn't about a
stack trace.
Anything you don't catch becomes INTERNAL_ERROR with the traceback logged rather than shown
to the model. Tools aren't supposed to raise; the wrapper is a backstop.
tctx.service("billing") fetches a dependency you registered at startup. Don't import your
database client directly into a tool — passing it through the context is what makes the tool
testable without one.
Register it
from core.tools.registry import ToolRegistry
from tools.billing import subscription_lookup
registry = ToolRegistry(
specs=[*support_tools(), subscription_lookup],
services={"billing": MyBillingClient(), "kb": kb, "helpdesk": helpdesk},
)
The keys in services are the names tctx.service(...) looks up.
Make it risky
If a tool spends money or is hard to undo, say so — in agentfast.yaml, not in code, so the policy
can change without a deploy:
tool_overrides:
issue_credit:
risk: high
hitl_mode: suspend
Now the agent stops and waits for a human before that call. Everything about the pause — surviving a restart, executing exactly once — applies automatically. You wrote no approval code.
The moment you're most likely to notice that a tool is dangerous is while you're writing it. Six weeks later, nobody remembers. If in doubt, mark it high-risk — an unnecessary approval prompt costs someone ten seconds; a missing one costs real money.
Test it without an agent
The decorator keeps your original function reachable, so you can call it directly:
async def test_subscription_lookup_rejects_bad_id():
with pytest.raises(ToolError) as e:
await subscription_lookup.handler(fake_ctx, customer_id="nope")
assert e.value.code == "INVALID_INPUT"
No model, no run, no database. Tool logic is ordinary code and should be tested like it.
Borrowing tools instead of writing them
If someone already built an MCP server for the system you're integrating — GitHub, Slack, Postgres, Linear — you don't need to write anything:
mcp_servers:
- name: linear
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-linear"]
env:
LINEAR_API_KEY: ${LINEAR_API_KEY}
Its tools show up namespaced (linear.create_issue), traced and rate-limited like native ones, and
you can mark them high-risk the same way. See Tools and MCP.
A good tool, briefly
- One job.
get_orderandrefund_order, notmanage_orderwith amodeparameter. - Narrow inputs.
order_id: strbeatsparams: dict. The schema is what stops the model inventing arguments. - Small outputs. Return the four fields the agent needs, not your whole database row. Every field you return is context you're paying for on every subsequent turn.
- Fail loudly and specifically.
NOT_FOUNDwith the id in the message lets the agent recover.INTERNAL_ERRORwith "something went wrong" doesn't.