Skip to content
agentFast
Build

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 description is a prompt, not a comment

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.

Type hints become the schema

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.

Failures are values, not exceptions

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.

Services come from the context

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.

CarefulDecide this while you're writing the tool

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_order and refund_order, not manage_order with a mode parameter.
  • Narrow inputs. order_id: str beats params: 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_FOUND with the id in the message lets the agent recover. INTERNAL_ERROR with "something went wrong" doesn't.