Guides
Building systems of agents
How to design multi-agent grafts that actually work — tool contracts, parameter descriptions, handoff discipline, the orchestrator pattern, and per-agent model assignment.
7 min read
A single @ag.agent entry point is fine for simple chat. Once you add multiple agents that hand off to each other, the quality of the system lives or dies on how clearly each agent and tool is described. This page covers the patterns that make multi-agent grafts reliable.
#Write tool descriptions for the model, not for humans
The tool docstring is what the LLM reads to decide whether to call a tool. Write it as if you're briefing a new colleague who has never seen your codebase. Answer: what does this tool do, when should it be called, and what does it return.
# Bad — obvious to a human developer, useless to a model
@ag.tool
def get_order(user, order_id: str) -> dict:
"""Get order."""
...
# Good — tells the model when to call it and what comes back
@ag.tool
def get_order(user, order_id: str) -> dict:
"""Fetch the full details of a single order: status, line items,
shipping address, and fulfilment timeline. Call this after the user
asks about a specific order they've already mentioned by ID. If no
order ID has been given, call list_orders first.
"""
...
#Describe every non-obvious parameter
The LLM fills in tool arguments using the parameter name, its type, and — critically — its description. Without a description, a parameter named since gives the model no signal about expected format; status gives no signal about valid values. Describe any parameter where the name alone is ambiguous.
The easiest way is a Google-style Args: block in the docstring — the SDK extracts it automatically, no imports needed. Use ag.Param when you need to override a single parameter explicitly or attach examples.
import agentgraft as ag
# Docstring Args: — zero friction, the recommended default
@ag.tool
def list_transactions(
user,
account_id: str,
status: Literal["pending", "cleared", "failed"] = None,
since: str = None,
limit: int = 50,
) -> list[dict]:
"""List transactions for an account, newest first.
Args:
account_id: UUID of the account to query, as returned by list_accounts.
status: Filter by transaction status — omit to return all statuses.
since: ISO 8601 date string (e.g. '2024-01-01'). Only return
transactions on or after this date. Omit for the most recent 30 days.
limit: Maximum number of transactions to return. Defaults to 50.
"""
...
# ag.Param — use when you need examples, or to override one param without
# touching the docstring
from typing import Annotated
@ag.tool
def get_transaction(
user,
transaction_id: Annotated[str, ag.Param(
"UUID of the transaction, as returned by list_transactions",
examples=["txn_01hwqz3m", "txn_01hwqz4n"],
)],
) -> dict:
"""Fetch the full details of a single transaction."""
...
Good parameter descriptions answer three questions: *what is this value*, *where does it come from* (e.g. "as returned by list_accounts"), and *what happens if I omit it*. They don't repeat information already visible from the type — don't say "a string" when the type already says str.
#Assign models intentionally
Every @ag.agent accepts a model argument. The default auto hands routing to Cailos. For multi-agent grafts, pinning a model per agent gives you predictable cost and latency: fast or cost-biased routes for dispatch, capable models for reasoning-heavy work. The value is a Cailos model codename — it is sent to Cailos verbatim, so auto:speed, auto:cost, claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5, gpt-5, and any other id Cailos supports are all valid.
@ag.agent(entry=True, handoffs=["analyst", "support"], model="auto:speed")
def router():
"""You are a routing agent. Read the user's first message and
hand off immediately — to 'analyst' for data questions, to 'support'
for account issues. Never answer directly; always hand off.
"""
@ag.agent(tools=[list_transactions, get_order, run_report], model="claude-opus-4-7")
def analyst():
"""You are a data analyst. Always call tools before drawing
conclusions. If a question requires multiple tool calls, make them
all before composing your answer.
"""
@ag.agent(tools=[get_account, update_contact_email], model="auto:cost")
def support():
"""You handle account and billing questions. Escalate back to
router if the question is outside account management.
"""
#Keep handoff instructions in the agent docstring
Handoffs are declared with handoffs=[...], but the agent that initiates a handoff needs instructions on *when* to use each target. Put those conditions directly in the agent's docstring — the model reads it as its system prompt fragment. Vague handoff instructions are the most common cause of agents looping or never handing off at all.
# Vague — the model doesn't know when to hand off
@ag.agent(entry=True, handoffs=["billing", "technical"])
def triage():
"""Help users and route them."""
# Precise — clear conditions, no ambiguity
@ag.agent(entry=True, handoffs=["billing", "technical"], model="auto:speed")
def triage():
"""You are the first point of contact. Greet the user and
immediately identify their need:
— Hand off to 'billing' for anything about invoices, charges,
refunds, subscription changes, or payment methods.
— Hand off to 'technical' for bug reports, API questions,
integration errors, or feature requests.
— If the intent is genuinely unclear after one clarifying
question, hand off to 'billing' as the safer default.
Keep your own messages to one sentence. You route; you don't solve.
"""
#Return structured data from tools
Tools should return dicts or lists of dicts rather than formatted strings. The model composes the natural-language response — your job is to give it accurate data in a shape it can reason about. Returning a pre-formatted string removes the model's ability to pick which fields matter for the user's specific question.
# Bad — pre-formatted, model can't reason about structure
@ag.tool
def get_account_summary(user) -> str:
"""Get the user's account summary."""
return f"Balance: ${user.balance:.2f}. Plan: {user.plan}. Due: {user.next_bill_date}"
# Good — structured, model picks what's relevant
@ag.tool
def get_account_summary(user) -> dict:
"""Return the user's current account standing: balance, plan tier,
next billing date, and any outstanding actions needed.
"""
return {
"balance_usd": float(user.balance),
"plan": user.plan,
"next_bill_date": user.next_bill_date.isoformat(),
"pending_actions": list(user.pending_actions.values("type", "due_date")),
}
#Use sub_agents for the orchestrator pattern
Handoffs transfer the conversation to another agent — the caller steps back and the target takes over. Sometimes you want the opposite: delegate a task, get a result back, and keep composing the final answer yourself. That is the *orchestrator pattern*, and it is what sub_agents=[...] is for.
A sub-agent runs its own tool loop internally and returns a string. The calling agent receives that string as a tool result and can incorporate it into its next response. The calling agent never hands over control — it stays in the driver's seat for the whole conversation.
@ag.agent(entry=True, sub_agents=["researcher"], model="auto")
def advisor():
"""You advise users on their portfolio. When you need up-to-date
market data or research on a specific symbol, delegate to 'researcher'
and use its result in your answer. Always compose the final response
yourself — never tell the user you are delegating.
"""
@ag.agent(tools=[search_news, get_price_history, get_fundamentals], model="claude-opus-4-7")
def researcher():
"""You are a research sub-agent. Given a symbol or topic, call the
tools available to gather current data, then return a concise factual
summary for the advisor to use. Return data only; do not editorialize.
"""
#Checklist before syncing a multi-agent graft
- Every tool has a docstring that explains *when* to call it, not just *what* it does.
- Every non-obvious parameter has a description — via docstring
Args:orag.Param— especially IDs, date strings, and enum-like values. Literal[...]is used for parameters with a fixed set of valid values so the schema lists the options explicitly.- Every agent that can hand off has explicit conditions for each target in its docstring.
- Orchestrating agents (
sub_agents=[...]) include instructions on *when* to delegate and how to use the result. - The entry agent uses
model='auto:speed'ormodel='auto:cost'if its only job is routing. - Tools return dicts/lists, not pre-formatted strings.
- Run
python manage.py agentgraft_sync --dry-runand read the printed payload — if a tool description looks thin, it will be thin to the model too.