Skip to main content

Google ADK for Java: Your First Multi-Agent App

Build two agents that hand work to each other with Google’s Agent Development Kit for Java 1.11.0: an LlmAgent with a tool, a SequentialAgent, and session state as the hand-off. Runs without an API key, using a scripted stand-in model that is labelled as such.

You have probably used a chat model by sending it text and reading text back. An agent is that same model wrapped in a little machinery: it can be given tools to call, it remembers a conversation, and it can be combined with other agents. Google’s Agent Development Kit (ADK) is a Java library for building exactly that wrapper. This post builds the smallest useful multi-agent app: a researcher that looks up a customer’s order with a tool, and a writer that turns the finding into a polite reply. The two run one after the other and pass their work through shared session state. You will meet the four ideas ADK is built on — agents, tools, sessions, runners — by watching them run.
Versions this was tested against. JDK 25 (Temurin 25.0.4.1+1), com.google.adk:google-adk 1.11.0 (the latest release on Maven Central at the time of writing; its metadata shows lastUpdated 2026-10-03), JUnit 6.1.3. Code: the adk module of java-ai-agents.
Read this before the output. The sandbox that built this post has no Gemini API key. So the model in every run below is ScriptedLlm, a small class I wrote that behaves like a tool-calling model (call the tool if you have one, answer from the tool result, otherwise write text). The agents, the tool call, the runner, the session and the state hand-off are all real ADK 1.11.0. The model’s answers are not real Gemini answers, and I make no claim about how Gemini would phrase anything.

What goes wrong first: there is no model to talk to

An ADK agent without a model is an empty shell, and a model normally means an API key, a network call and a bill. That makes the first real obstacle a testing one: how do you check that your agent wiring works without paying for every run? In ADK the model is just an abstract class, BaseLlm, with one method that matters, generateContent. You can subclass it.
public final class ScriptedLlm extends BaseLlm {

    public final List<LlmRequest> seen = new ArrayList<>();   // every request ADK sent

    public ScriptedLlm() { super("scripted-model"); }

    @Override
    public Flowable<LlmResponse> generateContent(LlmRequest request, boolean stream) {
        seen.add(request);
        // 1. a tool result is already in the conversation -> answer in text
        // 2. else if the request offers a tool           -> ask to call it
        // 3. else                                         -> write text from the instruction
        // ...
    }
}
Condensed from ScriptedLlm.java. The useful side effect is the seen list: because the stub records every LlmRequest, a test can assert what ADK actually sent to the model — which tools, which instruction — instead of guessing. One thing that cost a cycle: ADK appends an extra identity paragraph after your instruction text, so my first stub, which copied the whole instruction into its reply, copied that paragraph too. The stub now stops at the end of the line it wants.
What this does and does not prove. A scripted model proves the wiring: that the right tools are offered, that the second agent sees the first agent’s output, that sessions stay separate. It cannot prove the model will choose to call your tool, or call it with sensible arguments. That needs a real model and is where agent bugs actually live.

The four ideas, in plain terms

Agent — a model plus an instruction (“who you are and what to do”) plus optional tools. In ADK the workhorse is LlmAgent. Tool — a function the model is allowed to ask for. The model never runs code; it returns “please call lookupOrder with orderId=A-1001“, ADK runs your method, and the result goes back into the conversation. Session — one conversation for one user, holding its events (everything said and done) and a state map that agents can read and write. Runner — what you actually call. You give it an agent, a user id, a session id and a message; it runs the agent and streams back events.
Runner SequentialAgent “support_pipeline” researcherLlmAgent + tool lookupOrder() session statefindings = “…” writerLlmAgent, no tools The first agent leaves its answer in state; the second agent’s instruction contains {findings}, which ADK fills from state. No agent calls another. The shared state is the only hand-off.
The picture is the program. Nothing in the writer’s code mentions the researcher; the only coupling is the string findings. That is the pattern to take away: agents cooperate by agreeing on a state key, which keeps each one independently testable.

Going deeper: concepts

  • ADK documentation — the multi-language docs, including the Java pages.
  • adk-java on GitHub — the Java source and samples.
  • A2A in Java — for agents in different processes talking over a protocol; ADK’s own artifact list includes A2A modules.

A tool is a public static method

public static Map<String, Object> lookupOrder(
        @Schema(name = "orderId", description = "The order id such as A-1001") String orderId) {
    String status = ORDERS.get(orderId);
    if (status == null) {
        return Map.of("status", "error", "message", "no order " + orderId);
    }
    return Map.of("status", "ok", "orderId", orderId, "detail", status);
}
Source: OrderTools.java. ADK reads the method name and the @Schema annotations and turns them into a function declaration the model sees: that is why the description text matters. It is prompt text for the model, not a code comment. Returning a map with a status field, including an error status the model can read, is a deliberate choice: the model recovers better from “no order X” than from an exception.

Two agents and a SequentialAgent

LlmAgent researcher = LlmAgent.builder()
        .name("researcher")
        .model(model)
        .instruction("Find the order the customer mentions by calling the lookupOrder tool.")
        .tools(FunctionTool.create(OrderTools.class, "lookupOrder"))
        .outputKey("findings")
        .build();

LlmAgent writer = LlmAgent.builder()
        .name("writer")
        .model(model)
        .instruction("Write a short friendly reply to the customer using only this: {findings}")
        .build();

return SequentialAgent.builder()
        .name("support_pipeline")
        .subAgents(researcher, writer)
        .build();
Source: SupportPipeline.java. Two lines carry the design. outputKey("findings") tells ADK to store the researcher’s final text in session state under that name. {findings} in the writer’s instruction is a placeholder ADK fills from state when it builds the request. SequentialAgent simply runs its sub-agents in order inside the same session.

Running it and reading the events

Run the pipeline and print each event’s author and content:
researcher  call lookupOrder{orderId=A-1001}
researcher  result lookupOrder{orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok}
researcher  text: Findings: {orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok}
writer      text: Hello, here is your update. Findings: {orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok}
session state keys: [findings]
state[findings] = Findings: {orderId=A-1001, detail=shipped on 2026-10-09, carrier BlueDart, delivery expected 2026-10-15, status=ok}
Transcript: output/01-two-agents-in-order.txt. Read down the author column: the researcher asks for the tool, ADK runs it and records the result as an event, the researcher answers, then the writer speaks. The last two lines are the proof of the hand-off — the session now contains a findings key. The writer’s text is stilted because the stub just echoes; a real model would paraphrase.
Output ordering caveat. The entries inside the tool-result map can print in a different order from run to run, because they come from an immutable Map. Compare content, not line order. The module README says the same.
var runner = new InMemoryRunner(root, "support-app");
Session session = runner.sessionService().createSession("support-app", "user-1").blockingGet();

List<Event> events = runner.runAsync("user-1", session.id(),
        Content.fromParts(Part.fromText("Where is my order A-1001?"))).toList().blockingGet();
From SupportPipelineTest.java. ADK is built on RxJava, so the runner returns a Flowable of events; .toList().blockingGet() collects them for a test. In a real service you would subscribe and stream them instead of blocking.

Going deeper: tools and the runner

  • The tool and agent classes are small; read OrderTools and SupportPipeline side by side.
  • I used FunctionTool.create(Class, "methodName") only. ADK has other tool types; I did not run them.

How you know the state really reached the writer

The transcript shows a state key exists, but not that the writer’s model request contained it. The stub’s request log answers that directly:
model calls made: 3
call 1: tools offered = [lookupOrder]
call 2: tools offered = [lookupOrder]
call 3: tools offered = []
writer system instruction contains the findings: true
Transcript: output/02-state-injected-into-instruction.txt. Three model calls: the researcher is called twice (once to ask for the tool, once to answer from the result) and the writer once. The writer’s call offers no tools, which is correct, and its system instruction already contains the order details instead of the literal text {findings}. The test asserts both.

Sessions keep users apart

user-1 events: 5, keys: [findings]
user-2 events: 0, keys: []
Transcript: output/03-sessions-per-user.txt. Two users, one runner; only user-1 ran a request, and user-2’s session has no events and no state. A session is the unit of isolation, so the (app, user id, session id) triple is what you map to your own login and conversation ids. InMemoryRunner keeps sessions in memory, so they vanish on restart.
Reference: what else ADK ships, from the Maven Central listing I did not run any of these. They are what the com/google/adk/ directory on Maven Central lists and what each artifact’s own POM says it is, so treat them as pointers, not endorsements:
  • google-adk-spring-ai 1.11.0 — “Spring AI integration for the Agent Development Kit.”
  • google-adk-maven-plugin 1.11.0 — “Maven plugin for running Google ADK web server with custom agents.” This is the likeliest route to ADK’s local dev UI, but I did not start it.
  • google-adk-firestore-session-service, google-adk-a2a, google-adk-contrib-langchain4j — present in the listing; names only.

Deployment: what I can and cannot tell you

I did not deploy this anywhere, so there is no deployment output to show. What the project structure suggests: an ADK app is an ordinary JVM program. You build an agent object, hold a runner, and call it from whatever hosts you (a Spring Boot controller, a CLI, a function). The Maven plugin above exists to run a development web server around your agents. For production you would also replace InMemoryRunner‘s in-memory session storage with a durable session service, since a restart currently loses every conversation; the Firestore artifact in the listing is one candidate, untested here.

ADK versus Spring AI: pick by what you are building

Google ADKSpring AI
Main ideaAgents, sessions and a runner are the core typesA model and vector-store abstraction that integrates with Spring Boot
Multi-agentBuilt in: SequentialAgent, shown hereYou compose it yourself, or use a library such as Embabel
Conversation stateSession service with a state mapChat memory and your own application state
Spring integrationSeparate google-adk-spring-ai artifactNative
Read that table as my reading of the two designs, not a benchmark: I ran ADK in this post and used Spring AI in the code-review post and others, and I did not run them head to head. They are not exclusive either — the ADK artifact list includes a Spring AI integration. If your app is already Spring Boot and mostly one model call per request, stay with Spring AI (Agent Skills in Spring AI shows how far that goes). If you want a ready-made session, runner and sub-agent model, ADK is the closer fit.

Going deeper: choosing

Should you even do this? Two agents handing text to each other is often one agent with a better prompt and one tool. Split into several only when the roles need different tools, different instructions you want to test separately, or different models. Every extra model call costs money and latency; the three calls counted above would be three paid calls with a real model. And remember what this post could not test: real Gemini behaviour. Run your own evaluation before trusting tool-choice or phrasing.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.