A tool is not a function you happen to expose to a model — it is a contract you write for a reader who cannot see your code. The large language model driving an ADK agent never inspects your implementation; it sees only the name, the docstring, and the parameter schema, and from that alone it decides whether to call your tool, with what arguments, and how to interpret whatever comes back. Get that contract right and the agent feels intelligent; get it wrong and no amount of prompt tuning will save you. The companion overview article walks the ADK tool architecture — how declarations are extracted, how ToolContext and toolsets and long-running operations are wired. This piece is narrower and more opinionated: it is a catalog of design patterns for writing tools that an LLM can actually use well. Name the tool so the model knows when to reach for it, shape the arguments so wrong calls are ungenerable, return structured results with a status field, surface errors as data the model can act on, keep each tool single-purpose and idempotent, and choose the right tool type for the job. Throughout, we contrast a tempting but fragile version of a tool with the version you actually want to ship.

The declaration is the prompt: name, docstring, type hints

Every design decision downstream of this one is a rounding error by comparison: the model reads your tool the way it reads any other text in its context, so the name, docstring, and signature are the prompt for that tool. ADK builds the function declaration it sends to the model directly from these — the function name becomes the tool name, the docstring becomes the description, and the type hints become the JSON-schema of parameters. There is no second place to explain the tool; if it is not in the declaration, the model does not know it.

So write the docstring for the model, not for a fellow engineer skimming an IDE tooltip. State plainly what the tool does, when to use it, and critically when not to — the negative guidance prevents a real class of misfires. Describe every argument in domain terms, and describe the return shape so the model knows what it is getting. A tool named proc with the docstring ‘processes the data’ is invisible reasoning fuel; the model will guess. The same function, well-declared, becomes a precise instrument.

# BEFORE: opaque — the model has to guess what this is for
def proc(x: str, f: bool = False) -> dict:
    """Processes the data."""
    ...

# AFTER: the declaration teaches the model how to use it
def lookup_order(order_id: str, include_items: bool = False) -> dict:
    """Fetch the current status of a customer order by its ID.

    Use this when the user references a specific order and you need its
    status, dates, or line items. Do NOT use it to search by customer
    name or email — use `search_orders` for that.

    Args:
        order_id: The 8-digit numeric order ID from the confirmation
            email (e.g. '45810923'), NOT the shipment tracking number.
        include_items: Set True to also return the line items; leave
            False for a lightweight status-only lookup.

    Returns:
        A dict with a 'status' field ('ok' or 'error') and, on success,
        the order's state, dates, and (optionally) items.
    """
    ...
Advertisement

Type hints are guardrails, not documentation

Type hints do double duty in ADK: they document intent and they constrain what the model is allowed to emit. The parameter schema derived from your signature is enforced, so a well-chosen type makes an entire category of bad calls impossible to express rather than merely discouraged. The single highest-leverage move is to close open sets with enums. A str priority argument invites "high", "High", "urgent", "P1" — every synonym the model can imagine. An Enum or Literal collapses that to the three values your backend actually accepts, and the model can only pick from them.

The same discipline applies to structure. Prefer a small set of typed, named parameters over one dict or JSON string the model must assemble blind. Every field you name and type is a field the model gets schema help on; every **kwargs or free-form blob is a field it must hallucinate the shape of. Make required things required and give sensible defaults to the rest, so the model does not have to invent values it should not be choosing.

# BEFORE: stringly-typed — the model can send any string it dreams up
def create_ticket(subject: str, priority: str, payload: str) -> dict:
    """Create a support ticket. payload is a JSON string of fields."""
    ...

# AFTER: the schema itself rules out invalid calls
from enum import Enum
class Priority(str, Enum):
    low = 'low'; medium = 'medium'; high = 'high'

def create_ticket(subject: str, priority: Priority,
                  customer_id: str, notify_email: bool = True) -> dict:
    """Create a support ticket for an existing customer."""
    ...

Return structured dicts with a status field

Whatever your tool returns is fed straight back into the model’s context as the function response, so the return value is as much a part of the contract as the arguments. A bare scalar — a string, a number, None — is a missed opportunity: the model has to infer from prose whether the call worked and what to do next. The durable pattern is to always return a dict with an explicit status field plus a small, named payload. The status gives the model an unambiguous branch point; the named fields give it structured data instead of a sentence to parse.

Consistency across your whole catalog matters more than any single shape. If every tool returns {"status": "ok", ...} or {"status": "error", "message": ...}, the model learns one protocol and applies it everywhere, and your own error handling in callbacks becomes uniform. Return the fields the model will actually reason over, named clearly, and nothing it does not need.

# BEFORE: bare value — did it work? what does -1 mean?
def get_balance(account_id: str) -> float:
    return db.balance(account_id)  # or -1 on failure (!)

# AFTER: structured, self-describing, branchable
def get_balance(account_id: str) -> dict:
    row = db.balance(account_id)
    if row is None:
        return {'status': 'error', 'reason': 'not_found',
                'message': f'No account {account_id}.'}
    return {'status': 'ok', 'balance': row.amount,
            'currency': row.currency, 'as_of': row.updated_at}

Errors are data: don’t raise blindly

When a tool raises an uncaught exception, the model does not get a Python traceback it can reason about — it gets, at best, an opaque error string, and often the single most useful piece of information (what the agent should do about it) is buried in a stack frame the model never sees. The pattern is to catch expected failures and return them as structured data, with a machine-readable reason and a human-readable, action-oriented message. ‘The inventory service timed out; this is transient and safe to retry in a few seconds’ lets the model recover gracefully. A raw TimeoutError bubbling up just makes it flail.

Distinguish the two kinds of failure. Expected failures — not found, invalid input, rate limited, unauthorized, downstream timeout — are part of the tool’s normal contract and should be returned as error results the model can branch on. Unexpected failures — a genuine bug, a programming error — can still raise, because you want those loud in your logs and eval failures, not silently swallowed. The art is telling the model what it can act on without hiding the bugs you need to fix.

# BEFORE: raises — the model sees an opaque failure, can't recover
def reserve_inventory(sku: str, qty: int) -> dict:
    resp = inventory.reserve(sku, qty)   # raises on timeout / 409
    return {'reserved': resp.id}

# AFTER: expected failures become actionable results
def reserve_inventory(sku: str, qty: int) -> dict:
    try:
        resp = inventory.reserve(sku, qty)
    except inventory.OutOfStock as e:
        return {'status': 'error', 'reason': 'out_of_stock',
                'available': e.available,
                'message': f'Only {e.available} of {sku} in stock.'}
    except inventory.Timeout:
        return {'status': 'error', 'reason': 'timeout',
                'retryable': True,
                'message': 'Inventory service is slow; safe to retry.'}
    return {'status': 'ok', 'reservation_id': resp.id}

Keep tools single-purpose: the god-tool anti-pattern

The strongest pull in tool design is toward the god-tool: one manage_order function with an action argument that can be "lookup", "cancel", "refund", "update_address", or "split_shipment", and a grab-bag of optional parameters that only apply to some of those actions. It feels efficient — one function, one registration — but it is a routing disaster. The model has to first pick the action, then guess which of the twelve optional arguments this action needs, and the schema cannot tell it that refund_amount is required when action is refund and meaningless otherwise.

Split it. Each distinct capability becomes its own tool with a precise name, a focused docstring, and exactly the arguments that operation needs — all required, none vestigial. Selection accuracy improves because the model chooses a verb-like tool directly instead of a tool and a mode, and validation improves because each schema is tight. The counter-worry is catalog size, but a handful of clear single-purpose tools routes far better than one overloaded one; when the catalog genuinely grows large, the answer is toolsets and sub-agents, not cramming verbs into an action string.

# BEFORE: god-tool — action switch + arguments that only sometimes apply
def manage_order(action: str, order_id: str,
                 new_address: str = '', refund_amount: float = 0.0,
                 reason: str = '') -> dict:
    ...

# AFTER: one tool per capability, each schema tight and honest
def cancel_order(order_id: str, reason: str) -> dict: ...
def issue_refund(order_id: str, amount: float, reason: str) -> dict: ...
def update_shipping_address(order_id: str, address: Address) -> dict: ...

Design for idempotency and safe retries

Agents retry. A tool result that reads as ambiguous, a turn that times out, a long-running operation that resumes twice from a duplicated webhook — any of these can cause the same tool to be invoked more than once with the same arguments. If your tool has side effects, that means double-charged cards, duplicate tickets, and two refunds where one was intended. The defense is to design mutating tools to be idempotent wherever the domain allows, so a repeated call is harmless.

The workhorse technique is an idempotency key: the caller (or the tool, derived deterministically from the arguments) supplies a key that the backend uses to collapse duplicate requests into one effect, returning the original result on repeats. Reads are naturally idempotent, so the discipline applies to writes. Where true idempotency is impossible, at least make the tool detect the duplicate — ‘a refund for this order was already issued 20 seconds ago’ — and return that as a clear result rather than silently doing it again. Idempotency is also what makes a long-running tool’s exactly-once resume contract hold at the business layer, not just the runtime layer.

# BEFORE: a second call charges the customer twice
def charge_card(customer_id: str, amount: float) -> dict:
    return {'status': 'ok', 'charge_id': gateway.charge(customer_id, amount)}

# AFTER: a deterministic key makes a repeat a no-op
def charge_card(customer_id: str, amount: float,
               order_id: str) -> dict:
    key = f'charge:{order_id}'          # stable across retries
    res = gateway.charge(customer_id, amount, idempotency_key=key)
    return {'status': 'ok', 'charge_id': res.id,
            'was_duplicate': res.replayed}

Use ToolContext for state, artifacts, and services

Not everything a tool needs belongs in its argument list. The user’s identity, the current session’s accumulated state, a database handle, the large blob a previous tool produced — forcing the model to pass these as arguments is both a security hole and a schema mess. ADK’s answer is ToolContext: declare it as a parameter and the runtime injects it; it never appears in the declaration the model sees. Through it the tool reads and writes session state (scoped keys the rest of the agent shares), loads and saves artifacts (the escape hatch for large payloads), and reaches the invocation’s auth and services.

This gives you a clean separation: model-chosen inputs are arguments; ambient inputs are context. A get_recommendations tool takes a category from the model but reads the signed-in user’s ID from context — the model can neither see nor spoof it. State is also how tools coordinate across a multi-step task without laundering everything through the model’s prompt: one tool writes state['cart_id'], a later tool reads it. Keep state keys namespaced and documented, and treat what you write there as part of your tool’s contract with the rest of the agent.

# AFTER: ambient inputs come from context, not from the model
from google.adk.tools import ToolContext

def get_recommendations(category: str,
                        tool_context: ToolContext) -> dict:
    """Recommend products in a category for the current user."""
    user_id = tool_context.state.get('user:id')   # not a model arg
    recent  = tool_context.state.get('user:recent_views', [])
    picks = recommender.for_user(user_id, category, recent)
    tool_context.state['last_reco_category'] = category
    return {'status': 'ok', 'items': picks[:5]}
Advertisement

Keep results small: summarize and offload large payloads

A tool return does not just answer this turn — it lands in the conversation and the model drags it through every subsequent turn of the session. Dump a 200 KB JSON document into a function response and you have poisoned the context: token cost climbs, latency climbs, and the signal the model actually needed drowns in fields it does not. The pattern is a result-size budget: return a compact, decision-relevant summary, and offload the bulk elsewhere.

The mechanism is artifacts via ToolContext. When a tool fetches something large, save the full payload as an artifact, and return a small summary plus the artifact reference. The model reasons over the summary; if it or a later tool needs the full document, it is retrievable by reference without ever sitting in the prompt. A good rule of thumb is to summarize or offload anything past a few kilobytes, and to prefer returning the three fields the model will branch on over the eighty fields the API happened to include. Truncation with an explicit ‘showing 5 of 240 results’ marker beats silent flooding every time.

# BEFORE: floods the context with a huge document forever
def fetch_report(report_id: str) -> dict:
    return {'status': 'ok', 'report': api.get(report_id)}  # 200 KB

# AFTER: lean summary in-context, full payload offloaded
def fetch_report(report_id: str, tool_context: ToolContext) -> dict:
    doc = api.get(report_id)
    ref = tool_context.save_artifact(f'report_{report_id}.json', doc)
    return {'status': 'ok', 'summary': doc['executive_summary'],
            'row_count': len(doc['rows']), 'artifact': ref}

Choose the right tool type for the job

ADK offers several tool kinds, and picking the wrong one is a design error no docstring can fix. Match the mechanism to the shape of the work.

KindUse it when…
FunctionTool (a plain typed function)The work completes within a turn: a lookup, a calculation, a single API call. This is the default and covers most tools.
LongRunningFunctionToolThe work outlives a turn — a batch job, a pipeline, or a human approval. It returns a handle immediately and the agent resumes when the operation completes.
Agent-as-a-toolThe sub-task needs its own reasoning, its own narrow tool catalog, or a different model — wrap a specialist agent and expose it as a tool the orchestrator calls.
Built-in tools (grounded search, code execution)The platform already provides the capability with its own execution path; do not reimplement it as a function.

The most common miss is blocking on slow work inside an ordinary FunctionTool — it ties up the session, dies on restart, and cannot model a human gate. The moment a tool’s natural duration exceeds a turn or requires a person’s decision, it wants to be long-running. The second common miss is reaching for an agent-as-tool when a deterministic function would do: reserve the extra reasoning layer for genuinely open-ended sub-tasks, because it costs latency and non-determinism.

Auth-carrying tools: scope credentials, trigger consent

A tool that touches a protected system is a security boundary, and the model driving it is untrusted — prompt injection rides in on every document the agent reads. So credentials must live in the tool, never in the model’s arguments or context. Two patterns cover most needs. For system-to-system access, inject a scoped service credential at call time from a secret manager, granting the minimum the tool needs and no more. For access on behalf of a person, use a user-delegated OAuth flow: the first sensitive call triggers a consent prompt, the resulting token binds to this user’s session, and the tool executes as them — so the downstream audit log shows the user’s identity, not a shared service account.

Design consequences follow. Never accept a token, password, or API key as a tool argument — that puts a secret in the model’s context, where it can leak into logs or a later response. Read the caller’s identity from ToolContext, not from an argument the model fills in, so it cannot be spoofed. And gate genuinely sensitive actions behind an explicit consent or approval step (often a long-running tool), rather than letting the model authorize itself. The agent’s real capability envelope is exactly the union of what its tools permit; design that envelope on purpose.

Name and disambiguate arguments the model can misread

Selection is only half the battle; the other half is the model calling the right tool with the wrong arguments. Most of those failures trace back to ambiguity you could have designed out. Two arguments of the same type sitting next to each other — transfer(from_account: str, to_account: str) — invite transposition; the model has a coin-flip chance of swapping them under load. Disambiguate with names that carry meaning, descriptions that pin down the exact expected form, and types that constrain the value.

Booleans are a subtle trap: a call bristling with include_items=True, notify=False, dry_run=True, force=False is hard for the model to keep straight, and easy to get backwards. Prefer enums or a small typed object when several flags interact, and name each boolean for its true meaning so its intent is legible in isolation. Give an argument the form the domain uses — a date as an ISO string with the format spelled out in the docstring, an ID with its exact shape shown — and the model stops guessing. Every ambiguity you remove from the signature is a wrong call that can no longer happen.

# BEFORE: two look-alike strings + boolean soup — easy to transpose
def transfer(a: str, b: str, x: float,
            n: bool = False, f: bool = False) -> dict: ...

# AFTER: names, types, and docs pin down every argument
def transfer_funds(source_account_id: str, dest_account_id: str,
                   amount_usd: float, notify_sender: bool = True) -> dict:
    """Move funds between two accounts owned by the current user.

    Args:
        source_account_id: Account the money leaves (the debit side).
        dest_account_id: Account the money arrives in (the credit side).
        amount_usd: Positive amount in US dollars, e.g. 49.99.
    """
    ...

Make side effects visible to the model

A quiet but damaging anti-pattern is the tool whose real effect the model cannot see in the result. If send_email returns nothing on success, the model does not actually know the mail went out; it may re-send, or claim it did something it cannot confirm. If a tool silently mutates state that a later tool depends on, the model reasons on a stale picture. The rule is simple: the result should describe what changed. Return the created record’s ID, the new status, the count affected — enough for the model to know the effect happened and to reference it later.

This is also how you keep the model honest with the user. An agent can only truthfully say ‘I’ve created ticket #4812 and emailed you the confirmation’ if the tools that did those things handed back the ticket number and an email-sent acknowledgement. Invisible side effects force the model to either under-report (unhelpful) or fabricate (dangerous). Pair this with the small-result discipline: return the facts of the change, compactly, not a verbose dump — a confirmation ID and status, not the entire mail server response. Effects the model can see are effects the model can reason about, retry safely, and honestly report.

A design checklist and the anti-pattern catalog

Pulling the patterns together, a tool is ready to ship when the answer to each of these is yes: the name says what it does in the domain’s verbs; the docstring covers what, when, and when-not, and describes every argument and the return; the types constrain values (enums over free strings, named fields over blobs); it returns a structured dict with a status field; expected errors come back as data with actionable messages; it is single-purpose and, if it mutates, idempotent; ambient inputs come from ToolContext, not arguments; results are small, with large payloads offloaded to artifacts; side effects are visible in the return; secrets are never arguments; and the tool kind matches the work’s duration and nature.

Anti-patternWhy it hurtsThe fix
God-tool with an action switchWrecks selection and argument validationOne tool per capability
Ambiguous / look-alike argumentsThe model transposes or hallucinates themMeaningful names, enums, docstring formats
Raising on expected failuresThe model gets an opaque error it can’t recover fromReturn errors as structured data
Invisible side effectsForces under-reporting or fabricationReturn what changed (IDs, status)
Giant result payloadsPoisons the context for the rest of the sessionSummarize + offload to artifacts
Non-idempotent mutationsRetries double-charge, double-postIdempotency keys and duplicate detection
Secrets as argumentsLeaks credentials into the model’s contextInject via ToolContext / auth flows

None of these fixes require a smarter model. They are engineering the contract — and that contract, not the prompt, is where agent reliability is won.

Design each ADK tool as a contract the model reads, not a function you happen to expose. The name, docstring, and type hints are the prompt for that tool, so name it in the domain’s verbs, say when to use it and when not to, describe every argument, and constrain values with enums and named fields so wrong calls become ungenerable. Return a structured dict with a status field, and surface expected failures as actionable data rather than raising blindly. Keep each tool single-purpose — split the god-tool — and make mutating tools idempotent so retries are safe. Pull ambient inputs (identity, state, services) from ToolContext instead of arguments, keep results small by offloading large payloads to artifacts, and make every side effect visible in the return so the model can reason about and honestly report it. Match the tool kind — FunctionTool, LongRunningFunctionTool, agent-as-tool, or a built-in — to the work’s duration and nature, and never let a secret become an argument. Agent reliability is won at the tool boundary, in the contract you engineer, long before the prompt gets a vote.