An OpenAPI document already describes every operation, parameter and schema of a REST service, which is most of what a model needs to call it as a tool. Generating tools from it is attractive. The question is when the generation happens. ADK Java + OpenAPI builds a toolset that parses the spec when the agent starts. This article takes the other route: a build-time generator. It covers choosing operations, naming tools, lowering OpenAPI schemas into the subset the Gemini API accepts, emitting Java tools, and locking the result with golden tests. The model then sees declarations that a human reviewed in a pull request.

It assumes you know the tool basics. If not, read Writing a Custom Function Tool first.

What adk-java ships today

Check the inventory first, because examples written for the Python ADK leak into Java discussions. On 2026-10-08, the main branch of google/adk-java had no OpenAPI toolset anywhere in its tree. There was none in core and none in contrib, whose modules were the Firestore session service, LangChain4j, planners, samples and Spring AI. The Python ADK ships OpenAPIToolset, which creates one RestApiTool per operation; Java has no equivalent yet. What Java does provide are the extension points a generator targets: BaseTool (a name, a description, declaration() returning Optional<FunctionDeclaration>, and runAsync(Map<String, Object>, ToolContext) returning Single<Map<String, Object>>), BaseToolset, and FunctionTool.create(...), which reflects over a Java method. Re-check this before building, because a later release may add a toolset you should prefer.

Why generate at build time rather than parse at startup? Generated source shows up in code review. A changed spec becomes a diff, and a broken one fails the build rather than a production start. Startup does no parsing. Most importantly, the exact text the model sees is versioned with the code. Tool descriptions change model behaviour, so they deserve the same review as code.

Architecture

Build time turns the spec into reviewed source; run time only executes itBUILD (mvn generate-sources)openapi.yamlpinned version + checksumselect operationsx-agent-tool opt-inlower schemasto the genai Schema subsetname + describesanitise, detect collisionsemitdeclaration JSON + tool classgolden testdiff declarations in CItyped HTTP clientopenapi-generatorsame specRUN (agent process)LlmAgenttools(generatedToolset)generated tooldeclaration() + runAsync()binding maparg to path, query, bodyREST APIauth, timeouts, errorsfunction callships in jarNothing parses the spec at run time; the model sees declarations that were reviewed in a pull request.
The generator pipeline. Left: build-time stages. Right: what runs in the agent. The typed client and the tools come from the same pinned spec.

The data flow is one-way. The spec is pinned by version and checksum and stored in the repository. A Maven or Gradle step runs the generator during generate-sources. It writes one declaration JSON file and one tool class per selected operation. A test compares the declarations against committed golden copies. At run time, the agent receives a generated toolset. When the model emits a function call, the matching tool maps the arguments through a binding map to path, query and body. It then calls the API through a typed client and returns a compact map.

Selecting operations

Never generate a tool for every operation. Large specs have hundreds of operations; each declaration costs prompt tokens, and a long list makes the model pick worse. Make selection explicit in the spec with a vendor extension, so the owners of the API decide what agents may call:

paths:
  /orders/{orderId}:
    get:
      operationId: getOrder
      x-agent-tool:
        name: orders_get
        description: Look up one order by id. Use before answering any question about an order.
      parameters:
        - { name: orderId, in: path, required: true, schema: { type: string, pattern: "^ord_[a-z0-9]{12}$" } }
        - { name: expand, in: query, schema: { type: string, enum: [items, payments] } }
  /orders/{orderId}/refunds:
    post:
      operationId: createRefund
      x-agent-tool: { name: orders_refund_create, confirm: true }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefundRequest" }

The generator rejects an opted-in operation that lacks an operationId, uses multipart or binary bodies, or has no description anywhere. It marks every non-GET operation as needing confirmation unless the extension says otherwise. If the spec belongs to someone else, keep the same information in a side file that is keyed by operationId.

Naming tools

The genai FunctionDeclaration documents the name rule. A name must start with a letter or an underscore, may contain a-z, A-Z, 0-9, underscores, dots, colons and dashes, and may be at most 128 characters. Derive names deterministically, prefix them with a service namespace so two APIs cannot clash, and check for collisions after sanitising, because getUser and get_user become the same name:

static String toolName(String namespace, String operationId) {
    String snake = operationId.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
    String name = (namespace + "_" + snake).replaceAll("[^A-Za-z0-9_.:-]", "_");
    if (!Character.isLetter(name.charAt(0)) && name.charAt(0) != '_') name = "_" + name;
    if (name.length() > 128) throw new GeneratorException("tool name too long: " + name);
    return name;
}
// after naming every operation:
Map<String, String> seen = new HashMap<>();
for (Op op : selected) {
    String prev = seen.putIfAbsent(op.toolName(), op.operationId());
    if (prev != null) throw new GeneratorException(op.toolName() + " from " + prev + " and " + op.operationId());
}

Treat tool names as a public API. Evaluation sets, logs, saved sessions and prompts refer to them. A rename should be a deliberate change in the extension, not a side effect of someone renaming an operationId.

Lowering schemas to the genai subset

OpenAPI schemas are JSON Schema dialects. The genai Schema type the declaration uses is smaller. Its JSON fields are type, format, description, nullable, enum, properties, required, items, anyOf, minimum/maximum, minLength/maxLength, pattern, minItems/maxItems, minProperties/maxProperties, default, example, title and propertyOrdering. It has no allOf, oneOf, $ref, additionalProperties or const. The generator must lower each construct, and record what was lost so the tool can enforce it at run time:

OpenAPI constructlowered toenforced at run time
$refinlined; fail beyond a depth limit (recursive schemas)no
allOfmerged properties and required; conflicting types fail the buildno
oneOfanyOf with the same branchesyes: exactly one branch must match
discriminatoran enum on the discriminator propertyyes
const: Xenum: [X] (strings), else description textyes
nullable (3.0) or type [T, "null"] (3.1)nullable: trueno
additionalProperties mapsdropped, or an array of key/value objectsyes
readOnly propertiesremoved from request schemasno
exclusiveMinimum/exclusiveMaximumminimum/maximum plus a description noteyes
ObjectNode lower(JsonNode s, int depth) {
    if (depth > MAX_DEPTH) throw new GeneratorException("schema too deep: " + s);
    // also: type "string" -> "STRING", and a 3.1 type [T, "null"] -> type T plus nullable: true
    if (s.has("$ref")) return lower(resolve(s.get("$ref").asText()), depth + 1);
    if (s.has("allOf")) return lower(mergeAllOf(s.get("allOf")), depth + 1);
    ObjectNode out = JSON.createObjectNode();
    copy(s, out, "type", "format", "description", "enum", "pattern", "minimum", "maximum",
         "minLength", "maxLength", "minItems", "maxItems", "default", "nullable");
    if (s.has("oneOf") || s.has("anyOf")) {
        ArrayNode branches = out.putArray("anyOf");
        for (JsonNode b : s.has("oneOf") ? s.get("oneOf") : s.get("anyOf")) branches.add(lower(b, depth + 1));
    }
    if (s.has("const")) out.putArray("enum").add(s.get("const").asText());
    if (s.has("items")) out.set("items", lower(s.get("items"), depth + 1));
    if (s.has("properties")) {
        ObjectNode props = out.putObject("properties");
        s.get("properties").fields().forEachRemaining(f -> {
            if (!f.getValue().path("readOnly").asBoolean(false)) props.set(f.getKey(), lower(f.getValue(), depth + 1));
        });
    }
    if (s.has("required")) out.set("required", s.get("required"));   // minus readOnly names
    return out;
}

FunctionDeclaration also has a parametersJsonSchema field that accepts JSON Schema directly. It is mutually exclusive with parameters. It can carry constructs the subset cannot, but whether a given model and backend honour every keyword is something to test, not assume. The lowered parameters path is the conservative default.

Flattening inputs: a worked example

A REST operation splits its inputs across path, query, header and body. A function call has one argument object. The generator flattens them into one object schema. Path parameters are always required. Query parameters keep their own required flag. Body properties are hoisted to the top level when no name collides, and otherwise go under a body property. Auth headers are never exposed, because credentials come from the tool's configuration, not from the model. The generator also writes a binding map next to the declaration, such as orderId -> path, expand -> query, amount -> body.amount. The tool uses that map to rebuild the HTTP request. For orders_get, the lowered declaration is:

{
  "name": "orders_get",
  "description": "Look up one order by id. Use before answering any question about an order.",
  "parameters": {
    "type": "OBJECT",
    "properties": {
      "orderId": { "type": "STRING", "pattern": "^ord_[a-z0-9]{12}$" },
      "expand":  { "type": "STRING", "enum": ["items", "payments"] }
    },
    "required": ["orderId"]
  }
}

Emitting tools

There are two ways to emit tools. In strategy A, the generator writes thin wrapper methods over a typed client and lets FunctionTool.create(instance, "method") build the declaration by reflection. Parameter names come from @Annotations.Schema(name = ...), or from -parameters compilation as a fallback. This is simple, but @Schema carries only a name, a description and an optional flag. Enums survive only if the generator emits Java enum types, which reflection maps to a schema enum, but patterns and ranges from the spec have nowhere to go. In strategy B, the generator writes a BaseTool subclass that loads its lowered declaration from a resource with Schema.fromJson. That keeps every constraint the subset supports:

// GENERATED from openapi.yaml sha256=... DO NOT EDIT.
public final class OrdersGetTool extends BaseTool {
    private static final FunctionDeclaration DECL = Declarations.load("orders_get");
    private final OrdersApi api;                    // openapi-generator client (library: native)

    public OrdersGetTool(OrdersApi api) { super(DECL.name().get(), DECL.description().get()); this.api = api; }

    @Override public Optional<FunctionDeclaration> declaration() { return Optional.of(DECL); }

    @Override
    public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext ctx) {
        return Single.fromCallable(() -> {
            Violations v = Validators.check("orders_get", args);   // what lowering could not express
            if (!v.isEmpty()) return Map.of("error", "invalid_arguments", "details", v.messages());
            Order o = api.getOrder((String) args.get("orderId"), (String) args.get("expand"));
            return Trim.order(o);                                   // compact, model-sized result
        }).subscribeOn(Schedulers.io())
          .onErrorReturn(e -> ErrorEnvelope.of(e));
    }
}

Use openapi-generator's Maven plugin with generatorName java for the client. Its library option chooses the HTTP stack; the default is okhttp-gson, and native uses the JDK's java.net.http. Set apiPackage and modelPackage so generated code stays out of your own packages. Error envelopes are covered in Wrapping Errors from Tools.

Golden declarations and spec diffs

The declaration is the contract with the model, so test it like one. For every generated tool, serialise declaration().get().toJson() and compare it with a committed file under src/test/resources/golden/. The first run writes the files, and after that any difference fails the build until a human updates the golden copy in the same pull request. Then add a spec-diff check that classifies changes:

spec changeclassificationaction
new operation, not opted inno-opnone
opted-in operation removed or renamedbreakingfail; agents lose a capability
new required parameterbreakingfail; old calls become invalid
enum value removedbreakingfail; the model may still send it
description changedbehaviouralrequire review and an eval run
declaration size grew past budgetcostfail; trim descriptions or split tools

Description changes count as behavioural on purpose. The model reads descriptions to decide when to call a tool, so one edited sentence can change routing as much as new code. How Gemini consumes declarations is in Gemini Function Calling in ADK Java.

Failure modes

  • Generating everything. Hundreds of tools inflate every request and degrade selection; keep the explicit opt-in.
  • Silent lowering loss. oneOf became anyOf, but nothing checks exclusivity, so the API rejects calls the model thought were valid.
  • Name drift. An operationId rename changes the tool name and breaks evals and saved sessions.
  • Credentials in the schema. An auth header exposed as a parameter invites the model to invent tokens.
  • Recursive schemas. Without a depth limit, inlining $ref never terminates.
  • Untrimmed responses. Returning the full API payload burns context; emit a trimming step per tool.
  • Unpinned spec. Fetching the spec from a URL at build time makes builds non-reproducible.

Trade-offs

approachbest forcost
build-time generation (this article)stable APIs, regulated reviews, many operationsgenerator to maintain; rebuild per spec change
runtime toolset (live article)fast iteration, specs that change oftenparsing at startup; less review of model-facing text
hand-written FunctionToola handful of operations needing custom logicmanual drift from the spec
MCP server in front of the APIsharing tools across agents and languagesan extra service and transport

What to do next

  1. Confirm the current adk-java release still lacks an OpenAPI toolset; if one has appeared, evaluate it before building your own.
  2. Pin your spec in the repository and add x-agent-tool to three to five read-only operations.
  3. Implement naming with collision detection, and lowering with a depth limit, and fail the build on anything you cannot lower.
  4. Emit strategy B tools, plus runtime validators for whatever lowering dropped.
  5. Commit golden declarations, add the spec-diff classifier to CI, and run an eval when a description changes.
Key takeaway: adk-java has no general OpenAPI toolset today, so generate tools yourself, and do it at build time so every model-facing declaration is reviewed and versioned. Opt operations in explicitly, derive names under the 128-character rule with collision checks, lower allOf, oneOf and $ref into the genai Schema subset while enforcing what was lost at run time, and lock declarations with golden tests and a spec-diff check in CI.