A function tool is the smallest useful extension of an ADK agent: an ordinary Java method that the model may decide to call. The Agent Development Kit reflects over the method, builds a function declaration from its name, annotations and parameter types, sends that declaration to the model, and, when the model answers with a function call, binds the JSON arguments back to Java parameters, runs your code, and returns the result to the model as a function response.

This page builds one tool from an empty project to a tested agent: an order-status lookup for a support assistant, then a stateful variant and a refund tool that asks for confirmation. It deliberately stays practical. How Java signatures map to JSON Schema, and the type traps that come with it, is covered in the Java agent tools architecture guide; wrapping existing service methods is covered in ADK Java tools integration; and what the runtime does between the model's request and your method, including threading, is in tool dispatch mechanics. API details below were checked against the ADK Java source and adk.dev documentation at the time of writing; the kit is young, so re-check them against the release you use.

Advertisement

The shape of a function tool

Four things decide whether a tool works well, and only one of them is the code inside the method.

  1. The name, taken from the method name. The model chooses tools by name and description, so getOrderStatus beats handle.
  2. The description, from @Schema(description = ...) on the method. This is a prompt: say what the tool does and when to use it.
  3. The parameters, each with a name, a type and ideally its own @Schema description. Prefer few parameters with simple types: strings, numbers, booleans.
  4. The result, a Map<String, Object> the model reads on its next turn. In the current source other return values are converted to a map with Jackson where possible, and a value that cannot be, such as a String, is wrapped as {"result": value}, which works but tells the model less.

One function tool, end to end: from a Java method to a model-visible declaration and backJava methodstatic, @SchemaFunctionTool.createreflects signatureDeclarationname, description, paramsLlmAgent.tools(...)Modelemits functionCallrequestArgument bindingJSON to Java paramsYour method runsplus ToolContextMap resultor wrapped as resultfunctionResponseadded to session eventsnext model turnUncaught exceptions become a generic error map; the model never sees your stack trace or message
The declaration is built once when the tool is created; at run time the model's function call is bound to Java arguments, your method runs, and its result goes back to the model as a function response recorded in the session.

Step 1: project setup, and the compiler flag you must not skip

Add the ADK dependency and configure the compiler to keep parameter names. The adk.dev documentation calls out -parameters for ToolContext support, and the reason is concrete: without it, Java reflection reports parameters as arg0, arg1, and so on. In the current source, ADK takes a parameter's name from @Schema(name = ...) if present and otherwise from reflection, and it recognises the ToolContext parameter by its name being toolContext, not by its type.

<dependency>
  <groupId>com.google.adk</groupId>
  <artifactId>google-adk</artifactId>
  <version><!-- current release from adk.dev --></version>
</dependency>

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <release>17</release>
    <compilerArgs>
      <arg>-parameters</arg>   <!-- keeps real parameter names for reflection -->
    </compilerArgs>
  </configuration>
</plugin>

Take the version from the ADK Java release notes rather than from an article. If you build with Gradle, add -parameters to options.compilerArgs for the Java compile task. A quick check that the flag took effect: a tool with an unannotated String city parameter should appear to the model as city, not arg0.

Advertisement

Step 2: write the method

The worked tool looks up an order. Three decisions are baked into it. It is static, because FunctionTool.create(Class, String) searches only static methods and fails with "Static method ... not found" otherwise. It validates its input before touching anything, because the model can and will send malformed ids. And it returns a status field on every path, so the model can tell success from failure without guessing.

package com.example.orders;

import com.google.adk.tools.Annotations.Schema;
import java.util.Map;

public final class OrderTools {

  private OrderTools() {}

  @Schema(description = "Look up the shipping status of one customer order. "
      + "Use only when the user gives an order id such as ORD-12345.")
  public static Map<String, Object> getOrderStatus(
      @Schema(name = "orderId",
              description = "Order id in the form ORD- followed by 5 digits")
      String orderId) {

    if (orderId == null || !orderId.matches("ORD-\\d{5}")) {
      return Map.of("status", "error",
          "error_code", "INVALID_ORDER_ID",
          "message", "Order ids look like ORD-12345. Ask the user to check it.");
    }
    return OrderStore.find(orderId)
        .<Map<String, Object>>map(o -> Map.of(
            "status", "success",
            "order_id", o.id(),
            "state", o.state(),              // e.g. SHIPPED
            "carrier", o.carrier(),
            "estimated_delivery", o.eta().toString()))
        .orElseGet(() -> Map.of("status", "error",
            "error_code", "NOT_FOUND",
            "message", "No order " + orderId + " exists for this account."));
  }
}

Notice the description does double duty. "Use only when the user gives an order id" steers the model away from calling the tool speculatively, and the parameter description shows the expected format, which cuts malformed calls. The error messages are written for the model to relay to a human: they say what went wrong and what to ask for next. Keep the returned map small; the whole response goes into the conversation history and is re-sent on later turns.

Step 3: register the tool on an agent

Wrap the method with FunctionTool.create and pass it to the agent builder. The instruction should mention the tool by name and say how to react to its error shape.

import com.google.adk.agents.LlmAgent;
import com.google.adk.tools.FunctionTool;

public final class SupportAgent {
  public static final LlmAgent ROOT_AGENT = LlmAgent.builder()
      .name("support_agent")
      .model("<a Gemini model id your project can use>")
      .description("Answers questions about customer orders.")
      .instruction("You help customers with their orders. "
          + "When the user asks where an order is, call getOrderStatus with the order id. "
          + "If the tool returns status=error, explain the message and ask for a correction. "
          + "Never guess an order state.")
      .tools(FunctionTool.create(OrderTools.class, "getOrderStatus"))
      .build();
}

create fails fast at startup if the method cannot be found, which is what you want: a typo in the method name is a boot error, not a silent missing capability. Keep tool creation next to the agent definition so that a reviewer sees, in one place, exactly what the model is allowed to do.

Step 4: errors the model can act on

What happens when your method throws? In the current source, the call is wrapped in a catch-all that logs the exception and returns {"status": "error", "message": "An internal error occurred."} to the model. That is a safe default, since stack traces and internal messages never leak into the conversation, but it also means the model learns nothing about why the call failed and cannot recover.

So handle expected failures yourself and return them as data: invalid input, not found, permission denied, upstream unavailable. Give each a stable error_code and a short human-readable message. Let only genuinely unexpected failures escape as exceptions, and make sure they are logged with the function name and call id so that you can find them. For slow dependencies, bound the call rather than letting the turn hang; tool timeout handling covers the patterns.

SituationReturnWhy
Malformed argumentstatus=error, INVALID_ARGUMENT, expected formatThe model can re-ask the user or fix the call
Entity missingstatus=error, NOT_FOUNDNot a system fault; the model should say so
User not allowedstatus=error, FORBIDDEN, no detailsDo not reveal what exists
Dependency downstatus=error, UNAVAILABLE, retry hintThe model can apologise instead of inventing data
BugthrowGeneric error to the model, full detail in your logs

Step 5: remembering things with ToolContext

Tools often need the session: who the user is, what they already asked for, or a value an earlier tool computed. Declare a ToolContext parameter named exactly toolContext and ADK injects it instead of asking the model for it. ToolContext extends the callback context, so it exposes the session state(), and adds tool-specific members such as actions(), functionCallId() and searchMemory(query).

@Schema(description = "Record that the user wants delivery updates by email for one order.")
public static Map<String, Object> subscribeToUpdates(
    @Schema(name = "orderId", description = "Order id such as ORD-12345") String orderId,
    ToolContext toolContext) {                   // the NAME toolContext is what ADK matches

  Object prior = toolContext.state().get("subscribed_orders");
  List<String> orders = prior instanceof List<?> l
      ? new ArrayList<>(l.stream().map(String::valueOf).toList())
      : new ArrayList<>();
  if (!orders.contains(orderId)) {
    orders.add(orderId);
  }
  toolContext.state().put("subscribed_orders", orders);   // persisted with the session event
  return Map.of("status", "success", "subscribed", orders);
}

State written from a tool is recorded as a change on the resulting event, so it persists with the session through whatever session service you run. Two cautions. Everything you put in state is data the application trusts later, so write only values your code validated, never raw model text. And do not rely on the parameter's type: in the current source both the declaration builder and the argument binder look for the name toolContext, so a ToolContext parameter called ctx is neither excluded from the declaration nor injected, and the tool fails when it is created or when it is called. For hooks that run around every tool call rather than inside one, see ADK Java callbacks.

Step 6: instance tools, dependencies and confirmation

Static methods are fine for pure lookups, but a real tool needs clients, configuration and credentials. Use the create(Object instance, String methodName) overload so the tool is an instance method on an object you construct with its dependencies. That object is also what you mock in tests.

public final class RefundTools {
  private final PaymentsClient payments;         // injected, mockable in tests

  public RefundTools(PaymentsClient payments) { this.payments = payments; }

  @Schema(description = "Issue a refund for a delivered order. Amount in cents.")
  public Map<String, Object> issueRefund(
      @Schema(name = "orderId", description = "Order id such as ORD-12345") String orderId,
      @Schema(name = "amountCents", description = "Refund amount in cents, positive") long amountCents) {
    ...
  }
}

// instance method: use the Object overload; the boolean asks the user to confirm first
FunctionTool refund = FunctionTool.create(new RefundTools(paymentsClient), "issueRefund", true);

The boolean in that call is requireConfirmation. Overloads of create accept it, and ToolContext has requestConfirmation(...) for deciding inside the tool, so that a human approves before a side effect happens. Use it for anything that moves money, sends messages or deletes data. For work that takes minutes or hours, such as an approval workflow, ADK provides LongRunningFunctionTool.create(Class, methodName), which lets the agent report that the operation started and pick up the result later. Keep side-effecting tools idempotent regardless: the model can issue the same call twice, and a retry must not refund twice.

Step 7: test at two levels

Because a function tool is a plain method, most tests need no agent at all: call the method with good and bad arguments and assert on the map. Then add a small number of runner tests that prove the model actually chooses the tool for representative requests. Those call a real model, so run them in a separate, slower suite and tolerate some variance.

class OrderToolsTest {

  @Test
  void rejectsMalformedIds() {
    Map<String, Object> r = OrderTools.getOrderStatus("12345");
    assertEquals("error", r.get("status"));
    assertEquals("INVALID_ORDER_ID", r.get("error_code"));
  }

  @Test
  void agentCallsTheTool() {
    InMemoryRunner runner = new InMemoryRunner(SupportAgent.ROOT_AGENT);
    Session session = runner.sessionService()
        .createSession(runner.appName(), "test-user").blockingGet();
    Content msg = Content.fromParts(Part.fromText("Where is order ORD-00042?"));

    List<Event> events = runner
        .runAsync(session.userId(), session.id(), msg, RunConfig.builder().build())
        .toList().blockingGet();

    assertTrue(events.stream().anyMatch(e -> e.functionCalls().stream()
        .anyMatch(c -> c.name().orElse("").equals("getOrderStatus"))));
  }
}

The runner test uses the pattern from the ADK Java quickstart: an in-memory runner, a session created through its session service, and runAsync returning a stream of events. Assert on behaviour, such as "the tool was called with this id", rather than on the exact wording of the final answer.

Failure modes

SymptomCauseFix
Startup error: static method not foundInstance method passed to create(Class, name)Make it static or use create(instance, name)
Model sees parameters arg0, arg1Compiled without -parameters and no @Schema namesAdd the flag; name parameters in @Schema
Tool breaks once a context parameter is addedToolContext parameter not named toolContextRename the parameter to toolContext
Model says an internal error occurredYour method threwReturn structured errors for expected cases
Model never calls the toolVague name or descriptionSay what and when in the description; mention it in the instruction
Tool called with invented idsNo format guidance, no validationDescribe the format; validate and return INVALID_ARGUMENT
Duplicate side effectsModel repeated a callIdempotency keys and confirmation
Context grows every turnLarge result mapsReturn only the fields the model needs

Trade-offs

A function tool is the right choice when the capability belongs to this application and runs in-process. When the same capability must serve several agents or languages, exposing it as an MCP server or behind an API with a generated tool definition decouples the lifecycles at the price of a network hop and a second deployment. Many small, sharply described tools make the model's choice easier but cost prompt tokens for every declaration; a few broad tools save tokens but push decision-making into argument values the model can get wrong. Start narrow, measure which tools are selected and how often they fail, and merge only when the data says so.

What to do next

  1. Add -parameters to your build and confirm a tool's parameter names reach the model correctly.
  2. Write one static tool with method and parameter @Schema descriptions and a status field on every return path.
  3. Replace thrown exceptions for expected failures with error maps that carry a stable error code.
  4. Move any tool that needs a client or credentials to an instance created with its dependencies, and mock it in unit tests.
  5. Turn on confirmation for every tool with an irreversible side effect, and make those tools idempotent.
  6. Add one runner test per tool that proves the model selects it for a realistic request.
Key takeaway: A custom ADK Java function tool is a method plus the metadata the model reads: its name, @Schema descriptions and a small result map. Compile with -parameters, use static methods with create(Class, name) or instances with create(Object, name), name the context parameter toolContext, return structured errors because thrown exceptions reach the model only as a generic message, confirm side effects, and test the method directly before testing the agent's choice of it.