The ADK Java core module is a small library that makes a large number of decisions on your behalf, and most production trouble comes from not knowing which decisions those were. Core gives you an agent abstraction, a runner that owns the turn, a session that owns the conversation, an event stream that carries every side effect, a tool-calling loop, a model seam you can swap, and a callback lattice for everything else. It does not give you an HTTP server, a database, a scheduler, or a retry policy. This page is the map of that boundary: one section per component, each at the altitude where you can see how it connects to its neighbours, with a pointer to the article that goes deep. If you are here to learn how one piece works internally, follow the link. If you are here because you cannot tell where one piece ends and the next begins, read straight through.

What this page owns, and where the neighbours start

The ADK material on this site is deep and it is spread across two categories, which is confusing until you know the split. The ADK Framework category covers the concepts - what an event is, what a session is, what a callback can do - largely through the Python surface, because the concepts are shared across both language bindings. This ADK for Java category covers what changes when the runtime is a JVM: reactive types instead of async generators, a fluent builder instead of keyword arguments, a schema derived from a type system that was designed for a compiler, and an ops story shaped by threads, heap, and Maven.

This page is the index to both. It repeats none of the following, so if your question is one of theirs, go there first:

  • ADK overview - what the kit is, why it exists, and how it compares to LangGraph and CrewAI. That is the framework-level pitch; this page is the module-level inventory.
  • ADK runtime architecture - the Runner walked from construction to teardown, invocation by invocation.
  • ADK Java streaming - the event stream as an API surface: backpressure, schedulers, cancellation, resumability.
  • Java agent tools architecture - the contract between a Java signature and a model that will call it with arguments it invented.
  • Agent router architecture - dispatch across a tree of agents, and what a routing hop costs.

What is left, and what the rest of this page covers: the inventory of what core actually contains, how the pieces compose inside one JVM process, the path a single turn takes through all of them in order, and the boundary confusions that make newcomers write code that works in a test and breaks under concurrency.

Advertisement

What ships in core, and what is deliberately outside it

Core is one Maven coordinate. Adding it gets you the agent types, the runner, the session and state model, the event type, the tool machinery, the model seam, the callback hooks, and the in-memory implementations of every pluggable service. That last part matters more than it sounds: the in-memory session, artifact, and memory services are not toys bolted on for the tutorial, they are the reference implementations of interfaces you are expected to replace, and the fact that they ship in core is what lets a unit test wire a complete agent in four lines with no infrastructure at all.

You also inherit three transitive dependencies whether or not you wanted them, and each one leaks into your code. The Google Gen AI SDK supplies Content and Part - the types you build a user message from and destructure a model reply into - so those appear in your signatures the moment you touch the runtime. RxJava 3 supplies Flowable and Single, which is why every agent method returns a stream rather than a value, and why a reactive operator you have never heard of will eventually show up in a stack trace. A JSON binder drives tool-schema generation and argument deserialization, which is where most tool bugs actually live.

Everything else is a separate artifact or a separate concern:

ConcernIn core?Where it lives instead
Agent types, Runner, Session, Event, tools, callbacksYes-
In-memory session / artifact / memory servicesYes-
Local dev web UI and event inspectorNoseparate dev artifact, test scope
Evaluation harness and scoringNoADK evaluation
HTTP endpoint, DI container, lifecycle managementNoADK Java with Spring
Persistence, packaging, rolloutNoADK Java deployment, ADK deployment
Retries, timeouts, circuit breaking, rate limitsNocircuit breaker, rate limiter
Agent-to-agent transportNothe A2A articles in this category

Read the right-hand column as a design statement rather than a gap list. Core is a library, not a container: it never owns your process, never starts a thread pool on your behalf, and never decides how long anything is allowed to take. Everything in that column is a policy question with no single right answer, so core declines to answer it. The practical consequence is that a bare ADK Java service has no timeouts, no bulkheads, and no persistence until you add them, and none of those omissions produce a warning at startup.

The agent abstraction and its lifecycle

Everything that can run is an agent, and every agent shares one base type. That is what makes the tree work: any agent can be a sub-agent of any other, so an LlmAgent that reasons can sit under a SequentialAgent that does not, and neither needs to know what the other is. The families and the decision rule for picking between them are covered in ADK agent types; the deterministic ones are in workflow agents; the reasoning one is in the LlmAgent article. What follows is only the part that is specific to the Java module.

In Java an agent is assembled through a fluent builder and is immutable once built. You give it a name, a model, a description, an instruction, its tools, and its sub-agents, and then it does not change:

LlmAgent support = LlmAgent.builder()
    .name("support_root")
    .model("gemini-2.0-flash")
    .description("Front door for customer support requests.")
    .instruction("Resolve the request or transfer to exactly one specialist.")
    .tools(lookupOrder, issueRefund)
    .subAgents(billing, shipping)
    .build();

That immutability is a lifecycle statement in disguise. An agent is a startup-time singleton, built once when the process wires itself and reused by every concurrent turn for the life of the JVM. It is not a request object. The single most expensive Java-specific mistake in this module is putting per-conversation data in a field on a custom agent or a tool instance - a counter, a cached lookup, "the current user" - because that field is shared across every request in flight, and the corruption is invisible until two users are served at once. Everything that varies per turn lives in the invocation context and the session state, which are passed to you precisely so that your objects can stay stateless.

There is also no destroy hook. Core does not manage your objects' lifetime, so draining in-flight turns before a pod dies is your problem and is covered in graceful shutdown. Building the tree from configuration rather than hard-coding it is environment and configuration management, and choosing which specialist gets a turn is the router article.

Runner, Session, and the state that survives a turn

The Runner is the seam between your process and the agent runtime, and its lifetime is your process's lifetime. One instance, assembled at wiring time from an application name, a root agent, and whichever services you intend to use - a session service always, an artifact service and a memory service when you need them. Treat it the way you treat a connection pool: expensive to build, cheap to call, and shared by everything.

Per turn it performs the bookkeeping that no agent should have to perform for itself. Look the conversation up. Record the message that just arrived. Assemble the context object that every node in the tree reads from. Start the root agent. Write down each event the tree produces, in order. Pass those same events out to you. Notice what is absent from that list: judgment. Which tool fires and which specialist takes over are settled by the model and by how you defined the tree, never by the Runner, which only sequences and records. Holding the mechanical half apart from the reasoning half is exactly what lets one Runner serve a JUnit test, a servlet, an SSE endpoint, and a managed deployment with no change to either half. The full walkthrough is the runtime article.

The Session is the conversation. It is identified by a triple - application name, user id, session id - and it holds two things: the append-only event history, and a mutable state map. Those are different in kind and confusing them causes real bugs. History is the record of what happened, is never edited, and is what gets replayed into the model as context. State is a small key-value scratchpad for facts the agents want to hand each other, is scoped by key prefix so that a value can be per-session, per-user, or per-application, and is written through deltas that commit atomically with the event that caused them. That commit coupling is the important guarantee: there is no window in which an event is durable but the state change it announced is not. Sessions are their own article, and state has another.

Two things about this are specific to running it on a JVM. The identity triple is your input, not something the runtime derives: nothing in core reads a cookie, a JWT, or a servlet session, so mapping an authenticated principal to a user id and a conversation to a session id is code you write at the edge, and getting it wrong is how one customer ends up reading another's history. And the in-memory session service holds every session ever created on the heap for the life of the process with no eviction - fine for a test, a slow leak in a service that stays up for weeks, and the reason the first production incident on a new ADK Java service is disproportionately often an out-of-memory error rather than anything to do with agents.

The session service behind all of this is an interface. The in-memory implementation is what a test and a laptop use; a persistent implementation is what production uses; and the swap changes no agent code, because agents only ever see the resolved session through the context. That single substitution is the whole reason the local development loop and the deployed service run the same execution path - see ADK Java deployment for what changes on the way out.

The event stream is the spine

The structural fact to take from this page is a return type. An agent method in ADK Java does not hand you an answer, it hands you a Flowable<Event> - the stream is the API - and the Runner's job is to drain that stream, persist each item, and re-emit it to you. Every other component in this article communicates by putting something on it. The model puts text and function calls on it. A tool result comes back on it. A state write rides on it as a delta. A transfer to a sub-agent is an action field on it. An error is an event with error fields rather than a thrown exception. Nothing important happens off-stream.

That is what makes the rest of the system cheap. Observability does not need instrumentation hooks, because the trace is the event sequence you were already receiving. Routing decisions are testable without mocks, because a transfer is an event you can assert on. Streaming to a browser is a transformation of a sequence you already have, not a parallel code path. Resumption after a crash is possible at all, because the durable history is the same object the runtime consumed. The anatomy of the event - author, content parts, actions, partial flag, error fields - is the events article; what changes when you consume it as a reactive stream, including backpressure, thread hand-off, and mid-stream cancellation, is ADK Java streaming; reading it as telemetry is ADK Java observability.

One ordering consequence is worth stating plainly, because it is the property the rest of the platform is built on: persistence happens as the Runner drains the stream, so an event you have received is an event that is already durable. That is what makes recovery a matter of replaying history rather than reconstructing it, and it is why almost every debugging session on this module starts with "what does the event sequence say" rather than "what did the agent think".

Advertisement

Tools and the tool-calling loop

A tool is two things wearing one name: a declaration that the model reads, and an executable that your process runs. The declaration is a name, a description, and a JSON schema for the arguments; it is shipped to the provider as part of the request and it is the entirety of what the model knows about your capability. The executable is a Java method. Core generates the first from the second by reflection, and that generation step is where the Java binding diverges most sharply from the Python one.

The loop itself is short. The model emits an event whose content carries a function call - a name and a bag of arguments - and nothing has run yet. The runtime looks the name up, coerces the arguments into the method's parameter types, invokes it, and puts the return value back on the stream as a function-response event. That response is appended to the conversation and the model is called again, which may produce another call or may produce the final text. The loop terminates when a model turn contains no calls. Two things follow immediately: a turn is not one model call, and the number of model calls in a turn is decided by the model, not by you, which is why an unbounded tool loop is a cost incident rather than a hang.

Tools also get a context object that the model never sees, which is how a tool reads session state, writes a delta, or saves an artifact without any of that appearing in the schema. The conceptual loop, the context object, and what a tool may return are the tools article. The Java-side contract - parameter names surviving compilation, boxed versus primitive, erased generics, enum casing, and validation at the deserialization boundary - is Java agent tools architecture, and it is the article to read before you write your second tool. Deadlines and cancellation are tool timeout handling; work that outlives a turn is long-running tools; making a repeated call safe is idempotency.

The model abstraction, and what actually ports

An LlmAgent names its model with a string, and core resolves that string through a registry to an implementation of the model interface. That indirection is the whole provider-swapping story, and it is deliberately thin. The interface takes a request object - the conversation contents, the tool declarations, the system instruction, and the generation configuration - and returns a stream of response objects carrying candidate parts, a finish reason, and token usage. Implement that seam and any agent in your tree can address your model by name; nothing above the seam knows the difference.

What ports cleanly across that boundary is narrower than the interface suggests, and it is worth being explicit about, because "model-agnostic" is usually read as a stronger promise than anyone made. The request and response shapes port. Text generation ports. What does not port is precisely the behaviour agents depend on: tool-calling fidelity differs enormously between providers and tiers, and an instruction tuned until a strong model stops inventing arguments will not survive a downgrade; streaming granularity differs, so a UI tuned for one provider's chunk size looks broken on another; safety filtering and its refusal shapes differ; and token accounting differs enough that cost dashboards are not comparable across providers. Treat a model change as a behavioural change requiring re-evaluation, never as a configuration edit.

The practical shape this takes in production is a tiering decision rather than a portability one - a cheap model on the leaves, a strong model on the node that has to be right, and a documented downgrade path for when the strong one is unavailable. Failover mechanics are model fallback; keeping the model choice out of the code is configuration management; and protecting the process from a provider having a bad afternoon is the circuit breaker and the rate limiter.

Callbacks: the extension mechanism for everything else

Core exposes three boundaries and lets you sit on both sides of each: around the agent (before it runs, after it finishes), around the model (before the request goes out, after the response comes back), and around the tool (before the method is invoked, after it returns). Six hooks, one lattice, and they are the intended answer to almost every "can I intercept..." question about this module.

The return protocol is the part worth memorising because it is what makes them powerful rather than merely observational. Return nothing and the step proceeds as normal. Return a value and that value replaces the step, which is skipped entirely. One protocol delivers four different features: a before-model hook that returns a cached response is a semantic cache; a before-tool hook that returns an error object is authorization; a before-agent hook that returns a canned reply is a guardrail that refuses without ever spending a model call; an after-model hook that returns a rewritten response is output filtering. None of those need a framework feature of their own.

Two scope facts trip people up. Callbacks are attached to an agent, not to the application, so a hook on your root agent does not fire for a specialist the router transferred to - a policy you believed was global is enforced on exactly one node of the tree. And state is the only bus between hooks: a before-hook that wants to tell its after-hook something writes it to state, because they are separate invocations with no shared frame. The hook semantics in full are the callbacks article and its Java counterpart; genuinely cross-cutting concerns that should not be re-attached per agent are the plugin system. The two highest-value uses are authorization at the agent boundary and safety guardrails.

Artifacts and memory: the two stores that are not state

Session state is a small map of serializable values scoped to one conversation. Two things do not fit in it, and core gives each its own optional service rather than stretching state to cover them.

Artifacts are named, versioned binary blobs - the uploaded PDF, the generated chart, the CSV a tool produced. They are keyed to a session or to a user, they version on write rather than overwriting, and what travels in state is a reference, never the bytes. The rule of thumb is mechanical: if it has a filename or a MIME type, it is an artifact; if it is a fact you want the model to see inline, it is state. The trigger for reaching for artifacts is usually a state value growing until serialization becomes the slow part of a turn. See the artifact service and ADK Java artifacts.

Memory is searchable recall that crosses sessions. Session history already gives an agent perfect recall inside one conversation; memory is what lets it recall something from a conversation three weeks ago, and because "everything the user ever said" cannot fit in a context window, the retrieval is a search rather than a load. That makes memory a relevance problem with all the usual failure modes - stale facts resurfacing, retrieval that misses, and privacy obligations that state never had, because state dies with the session and memory does not. See the memory article and the Java MemoryService architecture; retrieval over your own documents rather than over past conversations is a different thing again and lives in RAG grounding.

Both are services you pass to the Runner, and there is no ambient default for either. Wiring them is an explicit decision rather than something the runtime arranges on your behalf, which is the right design - but it does mean a tool that saves a file and a tool that searches recall are each depending on a construction-time choice made somewhere else entirely, and neither dependency is visible in the tool's own signature.

One turn, end to end

Here is the whole module in one path. A user sends a message; a support agent looks up an order and answers. Nothing below is a new component - it is the six previous sections in the order they actually execute.

// wired once at startup: root agent + session service,
// plus an artifact service and a memory service if you use them
Runner runner = buildRunner();

// once per user message: the text wrapped as a Content
Content message = userMessage("where is order 88213?");

runner.runAsync(userId, sessionId, message)
      .subscribe(event -> {
          if (event.finalResponse()) commit(event);
          else render(event);   // partials and tool-call events, as they arrive
      });
  1. Your code calls runAsync. A stream is described; nothing has happened yet.
  2. You subscribe. Now the turn starts.
  3. The Runner resolves the session from the identity triple, loading its event history and state.
  4. The user message is appended to history as an event authored by the user.
  5. An invocation context is assembled - the session, the active agent, an invocation id stamped onto every event this turn produces, plus references to whichever artifact and memory services were wired - and handed down to the root agent.
  6. Before-agent callbacks fire. If one returns a value, the turn short-circuits here and you get that reply without a single model call.
  7. The agent builds a model request: the system instruction with any state templated into it, the conversation history, and the generated declarations for its tools. Before-model callbacks may rewrite it or answer it outright from a cache.
  8. The model responds. Its reply is an event on the stream. It contains a function call for lookupOrder.
  9. The Runner persists that event and emits it to your subscriber. Your UI can already say "checking your order" - this is the moment that matters for perceived latency.
  10. Before-tool callbacks fire; an authorization hook may refuse and substitute an error. Otherwise the arguments are coerced to the method's parameter types and the method runs. A tool that writes session state stages a delta rather than mutating anything directly.
  11. The return value becomes a function-response event, appended to history with its state delta committed atomically alongside it, and emitted to you.
  12. The loop re-enters the model with the tool result in context. This time the reply is text, so the loop ends.
  13. After-agent callbacks fire, the terminal event is persisted, and the stream completes. Your subscriber sees onComplete.

Count the model calls: two. Count the events on the stream: five or more, and many more with streaming enabled. Count the round trips your code initiated: one. That gap between what you called and what happened is the reason cost, latency, and traces on this platform never look the way a newcomer expects.

What newcomers get wrong about the boundaries

The components above are individually simple. Almost every early bug is a boundary confusion rather than a misuse of any one of them, and the same six recur.

State, history, and memory are three different stores

They get conflated constantly, usually as "the agent's memory". History is the append-only record of one conversation and is what the model actually sees. State is a small mutable map for facts the components hand each other, and it is not automatically visible to the model - it reaches the prompt only if something templates it in. Memory is cross-session search. The symptom of confusing the first two is an agent that ignores a fact you carefully wrote to state, because nothing ever put it in the prompt.

The Runner is not a request object

Constructing a Runner per request is the most common structural mistake, and it looks harmless because it works. It reconstructs services, discards whatever they cached, and in the in-memory case silently gives every request a fresh empty session store - so conversations lose their history and the bug reads as the model being forgetful.

An invocation is not a model call

One user message is one invocation, and one invocation is an unbounded number of model calls, tool executions, and possibly a transfer to another agent. Every event produced along the way is stamped with the same invocation id, and that stamp is what makes a trace legible: one sentence typed by a customer on one side, however many machine steps it provoked on the other, joined by a single key. Budgeting, timeouts, and cost attribution all belong at the invocation level; applying them per model call produces limits that mean nothing.

Transfer and agent-as-a-tool are opposite moves

Transferring to a sub-agent re-roots the invocation: the specialist now owns the conversation and the parent is out of the loop. Wrapping an agent as a tool calls it, gets a value back, and leaves the parent in control. Pick the wrong one and you get either a parent that goes silent when you wanted an answer back, or a parent that relays every message for a specialist it should have handed off to. The comparison is in multi-agent topologies.

The stream is cold, and the subscriber is the caller

Building a Flowable runs nothing; subscribing runs everything, and subscribing twice runs everything twice. The related Java-only hazard is that your onNext executes on whatever thread emitted the event, so blocking there occupies a thread from a pool sized for model calls. Both are covered in the streaming article, and the thread-sizing arithmetic changes on a modern JVM - see virtual threads.

The tool signature is a prompt, not just an API

A Java method exposed as a tool is read by a model that has only the generated schema to go on. Parameter names that reflection reports as arg0, a primitive int that cannot express "not supplied", a Map<String, Object> that erases to an untyped bag - each is a valid Java choice and a broken instruction. Treat the signature and its descriptions as prompt engineering with a compiler attached.

The ADK Java core module is a library, not a container: it supplies an agent abstraction built once at startup and immutable thereafter, a long-lived Runner that owns the turn, a Session that separates append-only history from a small mutable state map, an event stream that every other component communicates through, a reflective tool loop whose schema is generated from your Java types, a thin model seam, and six callback hooks that cover almost every interception you will want. It supplies no server, no persistence, no timeouts, and no retry policy, and it will not warn you about their absence. Hold three boundaries clearly and most early bugs disappear: history is not state and neither is memory; one user message is one invocation and many model calls; and the returned stream is cold, so nothing runs until somebody subscribes.