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
)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:
| Step | Who does it | What is produced |
|---|---|---|
| Render the catalog | Runtime | Declarations attached to the model request alongside history and instruction |
| Decide | Model | Either final text, or one or more function_call parts |
| Intercept | before_tool_callback | Optional: validate/rewrite args, or return a dict to short-circuit |
| Execute | Runtime | The tool’s run is awaited with parsed args + context |
| Post-process | after_tool_callback | Optional: reshape, redact, or replace the result |
| Feed back | Runtime | A function_response part appended to the conversation |
| Repeat | Model | Reasons 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 form | Scope | Typical use |
|---|---|---|
cart_id | This session | Working data for the current conversation |
user:tier | All sessions for this user | Preferences, entitlements, profile facts |
app:pricing_rev | All users of the app | Shared configuration and rollout flags |
temp:raw_rows | This invocation only | Scratch 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.
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.