Skip to content

Tools

Tools are how a Tulip agent affects the world. The model decides "call lookup_order with order_id='ord-4821'"; the SDK runs your lookup_order function, captures the return value, and feeds it back. From your side, a tool is a regular Python function with a @tool decorator — the SDK introspects the signature and docstring to build the schema the model sees.

This is the seam most production code touches. Get tools right and the rest of the framework gets out of your way.

When to write a tool

You want… Write a tool
The model to call your billing / CRM / infra APIs
Side-effecting actions the model should be able to invoke (refunds, deploys)
Read-only lookups (order status, deploy health checks)
To mutate the agent's internal state (system prompt, config) use a hook, not a tool
To intercept every tool call (logging, retry) use a hook

Getting started

1. Decorate a function

from tulip.tools import tool
@tool
def lookup_order(order_id: str, include_history: bool = False) -> dict:
    """Return the order record for ``order_id``; include past events when ``include_history``."""
    return billing.get_order(order_id, include_history)

The docstring becomes the tool description the model reads. Type hints (str, bool, dict) build the JSON schema. Defaults mark optional parameters.

2. Pass to the agent

agent = Agent(model="anthropic:claude-sonnet-4-6", tools=[lookup_order])

That's the wiring. The model now sees lookup_order in its tool list and can call it whenever it decides to.

3. Run it

result = agent.run_sync("What's the status of order ord-4821?")

If the model decides to call lookup_order("ord-4821"), the SDK invokes your function with that argument, captures the return value, and feeds it into the next model turn. You write Python; the SDK handles the schema marshalling.

What you get out of the box

Idempotent tools — the model can retry; the side effect can't

This is the SDK's flagship tool primitive. Some side-effecting tools must run exactly once per logical request — issuing a refund, paging on-call, rolling out a deploy. Mark them idempotent=True:

@tool(idempotent=True)
def issue_refund(order_id: str, amount: float) -> dict:
    """Refund ``amount`` against the order. Re-issuing the same
    (order_id, amount) within a single run returns the prior
    result; the body is not re-executed."""
    return billing.refund(order_id, amount)

When the model re-issues a tool call with the same (name, arguments) tuple that already ran in this agent run, the ReAct loop reuses the prior result instead of invoking the function again. Defends against:

  • Models that re-emit the same call after seeing the result.
  • Network glitches where a call appears failed but actually succeeded.
  • Users re-prompting "do X" when X has already been done.
  • Replays after a checkpoint resume.

Read the idempotency concept page for the full picture and the matching notebook.

Sync and async bodies

Both shapes are supported. Async bodies run on the agent's event loop directly; sync bodies run in a thread-pool executor so the loop is never blocked.

@tool
def score_priority(amount: float, age_days: int) -> float:
    return amount / max(age_days, 1)    # sync — runs in thread pool

@tool
async def query_siem(query: str) -> str:
    """Search the SIEM (a security team's log platform) for matching events."""
    async with httpx.AsyncClient() as c:
        return (await c.get(f"https://siem.example/search?q={query}")).text  # async — runs on the loop

Parallel by default — fast when the model wants multiple things

agent = Agent(
    model=...,
    tools=[lookup_order, lookup_customer, fetch_policy],
    tool_execution="concurrent",   # default
)

When the model emits multiple tool calls in one turn, the SDK runs them concurrently via asyncio.gather. Three independent lookups finish in max(t1, t2, t3), not t1+t2+t3.

If your tools have side effects that must be ordered, switch to tool_execution="sequential".

Error handling — tool failures don't crash the agent

If a tool raises, the loop catches the exception and turns it into a ToolResult(error=str(exc)), then feeds that back into the next model turn. (ToolResult.success is a read-only property derived from error — it's True when error is None, not something you set.) The model sees the failure and can react: retry, try a different tool, or report to the user.

@tool
def lookup_order(order_id: str) -> dict:
    record = billing.get_order(order_id)
    if record is None:
        raise ValueError(f"no order with id={order_id}")
    return record

The model sees "no order with id=ord-4821" and decides what to do. Behind the scenes, the loop captures the exception's string form into ToolResult.error; the raw exception is logged where the tool ran.

Custom names and descriptions

Override the auto-derived defaults when the function name doesn't read well to the model:

@tool(name="lookup_customer", description="Return the account record and open orders for a customer id.")
async def _lookup_customer_internal(customer_id: str) -> dict:
    ...

The model sees lookup_customer; your code keeps the internal name.

Practical recipes

Read-only lookups

@tool
def get_deploy_status(deploy_id: str) -> dict:
    """Return the current rollout status and health for a deploy."""
    return deploys.get(deploy_id)

No need for idempotent=True — read-only calls are safe to repeat.

Idempotent writes

@tool(idempotent=True)
def deploy_service(service: str, version: str) -> dict:
    """Roll ``service`` out at ``version``. Re-fires return the cached deploy id."""
    return infra.deploy(service, version)

A tool that's also exposed via MCP

If you've built a tool you want other agents to reach, expose it through TulipMCPServer — same @tool, no rewrite. See MCP.

Common gotchas

Symptom Likely cause
Model never calls the tool Description / docstring isn't telling the model when to use it. Be explicit: "Use this tool when the user asks about X."
Tool fires twice on the same input You're seeing the model retry. Add idempotent=True (a refund goes out once, not twice).
TypeError: missing 1 required positional argument at call time Function signature has a parameter without a default that you didn't surface in the docstring; the model omitted it. Add a default or explain the parameter.
Tool returns Python objects but the model echoes <__main__.X object at 0x…> Tool return value isn't JSON-serialisable. Return a dict / Pydantic model / list of strings, not arbitrary objects.
Async tool blocks the event loop The "async" body is calling sync I/O. Wrap the blocking call in asyncio.to_thread(...) or use an async client.

Source

See also

  • Idempotency — the full story on idempotent=True.
  • Hooks — for cross-cutting concerns (logging, retry, guardrails).
  • Executors — how concurrent vs sequential tool execution works.
  • MCP — expose your tools to other agents over the Model Context Protocol.
  • Errors — how tool failures surface in the event stream.