A tool in the Agent Development Kit is not really a Python function — it is a declaration the model reads, plus an execution path the runtime owns, joined by a call-and-response protocol that turns several times inside a single user turn. Most of what looks like agent behaviour is that protocol running: the runtime renders your tools into the model request, the model emits function calls, ADK dispatches them, wraps the returns as function responses, and hands everything back for another pass. This article is about that machinery, not about designing an individual tool — the companion piece on tool design patterns owns naming, schemas, idempotency, and the anti-pattern catalog. Here we stay on the system: how a typed function becomes a declaration, how the loop dispatches sync, async, and parallel calls, what ToolContext injects and why the model never sees it, what a return value looks like by the time it reaches the model, how errors propagate and where they stop, how built-in tools bypass your process entirely, and how long-running tools and toolsets bend the call-and-wait contract.

What a tool actually is in ADK

Everything you can put in an agent’s tools=[...] list collapses to one abstraction: a BaseTool with a name, a description, a way to produce a function declaration for the model, and an async entry point the runtime invokes with the parsed arguments. A plain Python callable is not special-cased — ADK wraps it in a FunctionTool for you, which is why tools=[lookup_order] and tools=[FunctionTool(lookup_order)] behave identically. Built-in tools, agent-as-a-tool wrappers, MCP-backed tools, and OpenAPI-generated tools are the same shape wearing different clothes.

That uniformity is the point. The model never learns that create_ticket is a local function while search_docs is a remote MCP call — it sees two declarations and picks between them on language alone. Where the kinds genuinely differ is the execution path — your process, a subprocess, HTTP, or the model provider’s side entirely — and the timing contract, which is what the rest of this article unpacks.

from google.adk.agents import LlmAgent
from google.adk.tools import FunctionTool

def lookup_order(order_id: str) -> dict:
    """Fetch an order by its 8-digit numeric ID."""
    ...

agent = LlmAgent(
    name="support",
    model="gemini-2.0-flash",
    instruction="Answer order questions.",
    tools=[lookup_order],          # auto-wrapped as a FunctionTool
)
Advertisement

From function to declaration — what ADK reads from your code

The declaration is generated, not written. ADK inspects the callable and assembles three things: the name from the function’s __name__, the description from its docstring, and a JSON-schema of parameters from the signature’s type hints. That schema is what the model is constrained to fill, so the hints are load-bearing runtime behaviour rather than editor decoration.

What survives the trip is narrower than Python’s type system. Scalars (str, int, float, bool), lists, dicts, Literal/Enum values, and Pydantic models map cleanly onto JSON-schema constructs. Exotic annotations — arbitrary classes, callables, complex unions, unresolvable forward references — either degrade to a loose object or fail outright. An unannotated parameter is the worst case: with nothing to constrain it, the model gets a free-form slot and fills it with whatever the conversation suggested.

Two cautions. The docstring becomes the description wholesale — an Args: block rides along inside that text rather than being guaranteed to split into per-property descriptions, which is fine as long as you write it for the model. And treat a Python default as a fallback in your implementation, not a promise to the model: ADK releases have differed on how faithfully defaults are conveyed in the declaration, so a tool should behave sanely when an optional argument simply is not sent.

The tool-calling loop, step by step

One user message is not one model call. Once an agent has tools, each turn runs a loop, and knowing its exact shape is what makes traces readable:

StepWho does itWhat is produced
Render the catalogRuntimeDeclarations attached to the model request alongside history and instruction
DecideModelEither final text, or one or more function_call parts
Interceptbefore_tool_callbackOptional: validate/rewrite args, or return a dict to short-circuit
ExecuteRuntimeThe tool’s run is awaited with parsed args + context
Post-processafter_tool_callbackOptional: reshape, redact, or replace the result
Feed backRuntimeA function_response part appended to the conversation
RepeatModelReasons again with the result in context, until it emits no more calls

Three consequences follow. Cost is multiplied by the loop: N rounds of tool calls means N+1 model invocations. Every result is permanent — the function response stays in the conversation for the rest of the session, which is why a 200 KB return is a tax paid on every subsequent step. And every step is an event: ADK surfaces the calls, responses, and state changes on the same stream the runner yields, so the loop is inspectable rather than a black box.

Async, sync, and parallel dispatch

The runtime is asynchronous end to end, so the natural shape for a tool that does I/O is async def — it is awaited on the running event loop. A plain def tool still works, but a blocking network call inside one is the commonest cause of an agent that feels mysteriously sluggish under concurrency. If the tool talks to a network, use an async client; if it must call a blocking library, push that call onto an executor yourself.

The payoff is parallelism the model hands you for free. Function-calling models can emit several function_call parts in one response when the calls are independent, and ADK dispatches them concurrently — a policy lookup and a shipment-status check that each take 400 ms cost 400 ms together, not 800 ms. Which is where thread-safety stops being theoretical: two tools running concurrently in one invocation share a view of state, so read-modify-write on the same key is a real race. Keep parallel-eligible tools side-effect-free where you can, write to disjoint keys where you cannot, and push genuinely ordered work behind a single tool or a SequentialAgent rather than hoping the model serialises it for you.

ToolContext — the parameter the model never sees

Declare a parameter annotated ToolContext and ADK injects it at call time; critically, it is excluded from the generated declaration, so the model neither sees it nor can forge it. That single exclusion is what makes the split between model-chosen inputs and ambient inputs enforceable rather than conventional.

Through the context a tool reaches the parts of the invocation it has no business taking as arguments: state for session-scoped key/value data, artifact save/load for payloads too big to live in the prompt, memory search where a memory service is configured, identifiers for the agent and invocation, and the id of the function call being serviced — the handle that matters for long-running tools. It also exposes an actions object, which is how a tool influences the runtime rather than merely answering: skip_summarization surfaces the tool’s output instead of letting the model paraphrase it, and the escalate/transfer flags let a tool end a loop or hand control to another agent.

from google.adk.tools import ToolContext

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

The declaration the model receives for that tool has exactly one property: category.

Writing state from a tool: deltas, prefixes, and commit

Assigning to tool_context.state[...] looks like mutating a dict, but it is really recording a delta. The write is staged, attached to the event the runtime emits for that tool call, and committed by the session service when the event is appended. That indirection is what makes state auditable: the session history is a replayable log of events with their state changes attached, not a mutable blob someone edited.

Key prefixes decide scope and lifetime, and getting them wrong is a quiet correctness bug rather than an error:

Key formScopeTypical use
cart_idThis sessionWorking data for the current conversation
user:tierAll sessions for this userPreferences, entitlements, profile facts
app:pricing_revAll users of the appShared configuration and rollout flags
temp:raw_rowsThis invocation onlyScratch data deliberately not persisted

Two habits keep this manageable. Namespace the keys a tool owns and document them like a public field — state is the coordination channel between tools, so an undocumented key is an undocumented API. And remember that state written by a tool is not automatically visible to the model: it reaches the prompt only if the return value carries it or the instruction templates it in. State is for tools talking to tools; the return value is for tools talking to the model.

What a tool may return, and what the model sees

A tool’s return value is serialised into a function_response and inserted into the conversation, so it is model-facing text no matter what you thought you were returning. A dict is the intended shape, because it arrives as named fields the model can branch on. Return something else — a string, a number, a list — and ADK wraps it in a dictionary under a single generic key so the protocol still holds; the call works, but you have handed the model an anonymous value instead of a labelled one.

Whatever you return must be JSON-serialisable. A datetime, a numpy array, or an ORM row will either fail or coerce into something unhelpful, so convert at the boundary: ISO strings for timestamps, plain floats for decimals, explicit dict projections for model objects. Pydantic models serialise cleanly when a result has real structure.

By default the model then summarises the response rather than emitting it verbatim — usually what you want, occasionally not. When a tool returns an exact quote, a rendered table, or a payload a downstream system must receive unaltered, set tool_context.actions.skip_summarization = True and the tool’s own output is surfaced. Size discipline matters here more than anywhere else in the framework: this value is not consumed once, it is carried for the rest of the session.

Advertisement

Errors: what propagates, and how far

There are two failure surfaces and they behave differently. An exception raised inside your tool does not reach the user as a traceback and does not reach the model as a rich object — the runtime catches it and reduces it to an error payload in the function response, an opaque string from the model’s point of view. What the model does next is a coin flip: retry, apologise, or invent a plausible answer. That is why an expected failure should be a return value with a reason and an actionable message, and only genuine bugs should raise.

The second surface is the callback pair. before_tool_callback receives the tool, the parsed arguments, and the context before execution: return None to proceed, or return a dict to short-circuit the call and feed that dict back as the result. This is the natural home for argument validation, per-user authorisation, and cheap caching — enforced in code rather than requested in a prompt. after_tool_callback sees the result and may replace it, which is where redaction, truncation, and schema normalisation live.

Both callbacks are agent-wide, so they are also where you enforce budgets centrally — a result-size ceiling, a per-tool timeout — and every tool inherits them. Framework errors surface as events too: a schema-validation failure, a hallucinated tool name, a dead MCP connection. Treat them as signal, not noise. Hallucinated names mean the catalog is ambiguous; repeated validation failures mean a type is too loose.

Built-in tools and their separate execution path

Some capabilities are not your code at all. Grounded search and native code execution are executed by the model provider’s infrastructure: you declare them on the agent, the request advertises them, and the results come back attached to the model response — there is no dispatch into your process, no ToolContext, and no callback interception in the way a function tool gets. In current ADK, grounded search is a tool object you add to tools=[...], while native code execution has moved onto the agent as a dedicated code_executor, which is worth checking against the version you are pinned to.

Because they ride the provider’s path, built-ins carry provider constraints, and the one that bites is composition: ADK has historically restricted mixing a built-in with arbitrary function tools on the same agent, and the restriction varies by model. The robust workaround is structural — put the built-in on its own small agent and expose that agent as a tool or sub-agent of your orchestrator. You get the capability, an isolated context, and a clean seam if the constraint changes. The corollary: do not reimplement a built-in as a function tool. A hand-rolled search wrapper loses the grounding metadata and citations the platform path returns, and a hand-rolled code runner loses the sandbox.

Long-running tools: returning before you are finished

The default contract is call-and-wait, and it breaks for anything that outlives a turn — a human approval, a batch job, a fulfilment webhook. LongRunningFunctionTool changes the contract: the tool returns immediately with a pending status and whatever handle the caller will need later, and the runtime marks that function call as long-running so the application can tell it apart from a completed one. The agent can then say ‘I’ve requested the upgrade’ and the turn ends cleanly.

Completion arrives as a new function response carrying the same function-call id, sent by your application when the external event lands. The model receives it as though the tool had just answered, and the conversation continues — one coherent transcript spanning ninety minutes and three systems. The id is the whole mechanism: capture it from the context or the emitted event, persist it next to your job or approval record, and correlate on the way back.

The operational obligations are unglamorous and non-optional. The session must still exist when the result returns, so these flows need a persistent session service, not an in-memory one. Resumption must be idempotent, because webhooks retry. And every pending operation needs a timeout path, because an approval nobody actions is a conversation that never finishes.

Toolsets: catalogs that are computed, not listed

A toolset is a tool provider: instead of a fixed object, you pass something the runtime asks for a list of tools, with the current context available. That indirection buys two things — catalogs sourced from somewhere external, and catalogs that differ per request.

The external sources are the headline. An MCP toolset connects to a Model Context Protocol server (a local stdio subprocess or a remote HTTP endpoint), enumerates the tools it exposes, and surfaces each one as a native ADK tool, schema included; connection lifecycle and remote auth are handled at the toolset boundary. An OpenAPI toolset compiles a REST specification into one tool per operation — instant breadth and instant danger, because a 214-operation spec becomes 214 declarations and routing accuracy falls off a cliff long before that. Both support filtering, and filtering is mandatory rather than optional: mount the six operations the agent needs and curate their descriptions, since specs are written for developers and read poorly as prompts.

The dynamic case is subtler and underused: because the toolset is consulted with context, an admin can be offered write tools that a read-only user never sees. The tool the model cannot see is the tool it cannot misuse — a stronger guarantee than any instruction, and cheaper than a guardrail.

Instrumenting the loop

Because every call, response, and state delta is an event, the tool layer is the most observable part of an ADK agent, and the first place to instrument. Log four numbers per tool: invocation count, latency distribution, error rate by reason code, and serialised result size. Runaway loops show up as invocation counts; context poisoning shows up as result size; a badly described tool shows up as an error rate concentrated in one reason. Those four diagnose most agent pathologies without reading a transcript.

Then keep a small eval suite per tool — the happy path, the most likely wrong-argument call, and the most likely downstream failure — and run it whenever a description or a schema changes. A docstring edit is a prompt change and deserves the same scrutiny as one, which is the whole lesson of this article restated: in ADK, the tool layer is not plumbing behind the model, it is part of the prompt, part of the control flow, and part of the security boundary all at once.

An ADK tool is a generated declaration plus a runtime-owned execution path, and almost everything surprising about agent behaviour lives in that seam. Type hints and the docstring are the schema and the description — runtime behaviour, not documentation. One turn runs a loop (render, decide, execute, feed back, repeat), which is why every result is carried for the rest of the session and why N tool rounds cost N+1 model calls. Independent calls dispatch concurrently, so shared state is a real race. ToolContext is injected and hidden from the model, making the split between model-chosen arguments and ambient inputs enforceable, and state written through it is an event delta scoped by user:, app:, and temp: prefixes. Return JSON-serialisable dicts, since anything else is wrapped anonymously, and raise only on bugs, because exceptions reach the model as opaque strings while returned errors are actionable. Built-in tools execute on the provider’s side and carry composition limits. Long-running tools return a pending handle completed later by a function response reusing the same call id, which demands persistent sessions and idempotent resumption. And toolsets compute catalogs rather than listing them, so filter MCP and OpenAPI mounts hard: the tool the model cannot see is the tool it cannot misuse.