A SequentialAgent in the Java Agent Development Kit (ADK) runs its sub-agents one after another: triage, then draft, then check. The ordering is the easy part. The part that decides whether the pipeline works is the handoff: what the second agent actually receives from the first, in what form, and what happens when the first step produces nothing, produces garbage, or produces something the second step cannot parse.

This article follows that handoff through the ADK Java source: the two channels context travels through, the exact rules for writing and reading state, a three-step support pipeline, guarding a handoff so a bad step fails loudly, and where intermediate data should live. Behaviour described here was checked against the adk-java main branch and the adk.dev documentation in September 2026; the Java getting-started page listed com.google.adk:google-adk version 1.6.0 at the time. ADK moves quickly, so re-check anything version-specific against the release you use.

Advertisement

What a SequentialAgent actually does

A SequentialAgent never calls a model. Its implementation takes the list of sub-agents and runs subAgent.runAsync(invocationContext) for each one using RxJava's concatMap, which subscribes to the next sub-agent's event stream only after the previous one completes. The documentation states the key property directly: the sequential agent passes the same InvocationContext to each of its sub-agents. Same session, same state, same invocation id.

So there is no return-value plumbing: step two never receives step one's output as an argument, and everything travels through the session. Each event a sub-agent emits is appended before the next sub-agent starts, so the next step sees every state change. In resumable mode, a run paused on a long-running tool call restarts at that sub-agent rather than the first.

One invocation, three steps, two context channelsSequentialAgent: concatMap over subAgents, same InvocationContextTriageAgentoutputKey = triageDraftAgentreads {triage}PolicyCheckAgentreads {triage} {draft?}thenthenChannel 1: session state (via event stateDelta)triage -> draft -> verdict ; temp: keys live only for this invocationwriteinjectinjectChannel 2: conversation history (session events)includeContents DEFAULT: prior agents' replies shown as context ; NONE: current turn onlyState is the contract you control. History is what the model also happens to see.
The sequential agent shares one InvocationContext across its steps. Context reaches a later step through two channels: explicit state keys injected into its instruction, and the conversation history the model sees.

Two channels: state and history

Context reaches a later step in two ways, and most confusion about sequential pipelines comes from mixing them up.

Channel one: session state. A key-value map on the session. A step writes a value, usually through outputKey, and a later step reads it, usually through a {key} placeholder in its instruction. This is explicit, named and inspectable. It is the contract you control.

Channel two: conversation history. An LlmAgent builds its model request from the session's events. With the default setting (IncludeContents.DEFAULT), a later step's request includes the conversation so far, and replies from other agents are rewritten as context attributed to that agent rather than as the current agent's own turns. So the draft step can see the triage step's reply even if you never inject it. With .includeContents(LlmAgent.IncludeContents.NONE), the request includes only the current turn, which the source defines as everything from the latest user message or the latest reply from another agent. In a sequential pipeline that usually means the immediately preceding step's reply is still visible.

Rely on channel one and treat channel two as a side effect: history follows framework rules that can change, grows with every step, and is hard to test. If step three needs step one's output, inject it by key, and consider IncludeContents.NONE on steps that should work only from their injected inputs.

Advertisement

Writing: what outputKey actually stores

Setting .outputKey("triage") on an LlmAgent tells the framework to copy the agent's final answer into state. The source is precise about which answer:

  • Only an event marked as a final response is saved. Intermediate tool-call events are not.
  • The event must contain at least one text part that is not a model "thought". A function-call-only event is skipped, so an existing value is not overwritten with an empty string.
  • Text from all non-thought parts is concatenated. Reasoning (thought) parts never land in state.
  • If the agent has an outputSchema, the text is parsed and validated, and the validated structured value is stored. If the text is not valid JSON, or does not match the schema, the framework logs an error and stores the raw string anyway.
  • The write goes into the event's stateDelta, so it is applied when the event is appended to the session and recorded in the event history.

Design around the fourth point: a malformed answer still flows downstream as a string, traced only by a log line. If the next step depends on structure, check it at the boundary.

Reading: the placeholder rules, including the ones that fail silently

When an LlmAgent's instruction is a string, ADK scans it for brace-delimited placeholders and resolves each against the current session. The rules, from InstructionUtils:

PlaceholderResolves toIf missing
{triage}String.valueOf of the state valueThrows IllegalArgumentException ("Context variable not found"); the step fails
{triage?}SameEmpty string; the model sees nothing there
{user:tier}, {app:x}, {temp:x}Scoped state valueSame as above, with or without ?
{artifact.notes.txt}The loaded artifact, as JSONThrows unless written with ?
{step.extract}, {ticket-id}Left in the instruction verbatimNo error: not a valid name, so it is not treated as a placeholder

Three practical lessons come out of that table. First, use required placeholders for real dependencies: a crash at step two is far better than a model improvising around an empty slot. Reserve ? for genuinely optional context such as a customer tier. Second, key names must be valid Java identifiers, optionally prefixed with app:, user: or temp:. A dotted or hyphenated key such as step.extract is never substituted, and the model receives the literal text {step.extract}. Namespace with underscores instead (extract_entities). Third, values are converted with String.valueOf, so a stored map arrives in Java's toString format, not JSON. If a later model needs JSON, store JSON text.

Literal braces in an instruction, such as the JSON example in the triage prompt below, are matched by the scanner too, but because their content is not a valid name they are passed through unchanged. The dangerous case is a brace pair around a bare word, such as {status} in an output template: that is a required placeholder, and the step fails if no such key exists. Test every instruction that contains braces.

Worked example: a three-step support pipeline

The pipeline triages a ticket, drafts a reply, and checks the draft against policy. Each step writes one key and reads the keys it needs. Note the key names are plain identifiers, and the tier lookup is optional because not every customer has one.

import com.google.adk.agents.LlmAgent;
import com.google.adk.agents.SequentialAgent;

public final class TicketPipeline {
  static final String MODEL = "gemini-2.5-flash"; // use a model your project has access to

  static final LlmAgent TRIAGE = LlmAgent.builder()
      .name("TriageAgent")
      .model(MODEL)
      .description("Classifies a support ticket.")
      .instruction("""
          Classify the customer's message. Reply with JSON only:
          {"category": "billing|outage|account|other", "urgency": 1-3, "summary": "<one sentence>"}""")
      .outputKey("triage")
      .build();

  static final LlmAgent DRAFT = LlmAgent.builder()
      .name("DraftAgent")
      .model(MODEL)
      .description("Drafts a reply from the triage result.")
      .instruction("""
          You write replies for the support team.
          Triage result: {triage}
          Known customer tier: {user:tier?}
          Draft a reply of at most 120 words. Do not promise refunds.""")
      .outputKey("draft")
      .build();

  static final LlmAgent POLICY = LlmAgent.builder()
      .name("PolicyCheckAgent")
      .model(MODEL)
      .description("Checks the draft against policy.")
      .instruction("""
          Triage: {triage}
          Draft: {draft}
          Reply APPROVE or REJECT: <reason>. Reject any refund promise.""")
      .outputKey("verdict")
      .build();

  public static final SequentialAgent ROOT = SequentialAgent.builder()
      .name("ticket_pipeline")
      .description("Triage, draft, then policy-check a support reply.")
      .subAgents(TRIAGE, DRAFT, POLICY)
      .build();
}

Running it with the in-memory runner, then reading the session back to see exactly what each step handed on:

import com.google.adk.events.Event;
import com.google.adk.runner.InMemoryRunner;
import com.google.adk.sessions.Session;
import com.google.genai.types.Content;
import com.google.genai.types.Part;
import java.util.Optional;

InMemoryRunner runner = new InMemoryRunner(TicketPipeline.ROOT);
Session session = runner.sessionService()
    .createSession(runner.appName(), "user-42")
    .blockingGet();

Content msg = Content.fromParts(Part.fromText("I was charged twice for September."));
runner.runAsync(session.userId(), session.id(), msg)
    .blockingForEach(e -> System.out.printf("%s -> %s%n", e.author(), e.stringifyContent()));

// Read back what each step handed to the next
Session after = runner.sessionService()
    .getSession(runner.appName(), session.userId(), session.id(), Optional.empty())
    .blockingGet();
System.out.println(after.state().get("triage"));
System.out.println(after.state().get("verdict"));

For the message "I was charged twice for September", a successful run leaves three keys in state: triage holding the JSON text, draft holding the reply, and verdict holding APPROVE or a rejection reason. Printing state after each development run, or each event's actions().stateDelta(), shows which step wrote what. The pipeline does not stop on REJECT: every step runs, so branching on the verdict belongs in application code or a guard.

Guarding the handoff with a callback

The weakest point in the pipeline is the boundary between triage and draft. If triage returns prose instead of JSON, the draft step still runs, the model still writes something, and nothing looks wrong. A beforeAgentCallback on the draft step turns that into a visible, deterministic outcome. In ADK Java the callback receives a CallbackContext, whose state() is a live map that records changes into the event's delta. Returning Maybe.empty() lets the agent run; returning content skips the agent's model call and uses that content as its output.

import com.fasterxml.jackson.databind.JsonNode;
import com.google.adk.agents.CallbackContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.genai.types.Content;
import com.google.genai.types.Part;
import io.reactivex.rxjava3.core.Maybe;

static final ObjectMapper JSON = new ObjectMapper();

// Registered on DraftAgent with .beforeAgentCallback(TicketGuards::requireTriage)
static Maybe<Content> requireTriage(CallbackContext ctx) {
  Object raw = ctx.state().get("triage");
  try {
    JsonNode t = JSON.readTree(String.valueOf(raw));
    if (t.hasNonNull("category") && t.hasNonNull("summary")) {
      ctx.state().put("temp:triage_ok", true);   // recorded in this event's stateDelta
      return Maybe.empty();                      // run the agent normally
    }
  } catch (Exception ignored) { }
  String escalation = "ESCALATE: triage output was missing or malformed";
  ctx.state().put("temp:triage_ok", false);
  // Returning content skips DraftAgent's run, so its outputKey never fires:
  // write the key the next step requires ourselves.
  ctx.state().put("draft", escalation);
  return Maybe.just(Content.fromParts(Part.fromText(escalation)));
}

One subtlety from the source: when a before-callback returns content, the agent's own run is skipped, and with it the outputKey write, so the guard must write draft itself or the policy step's required {draft} fails (or reads last turn's value). The callback also marks DraftAgent's own copy of the invocation context as ended; in the current source the sequential parent still runs the policy step, which then sees an explicit escalation instead of an invented reply. The temp:triage_ok flag is visible to later steps in this invocation and discarded afterwards. Callbacks are covered in more depth in ADK Java callbacks.

The same pattern handles the outputSchema fallback described above: check for the parsed structure you expect, and escalate if you get a raw string.

Choosing where intermediate data lives

ScopeLifetimeUse it forAvoid it for
No prefix (session)The session; persisted by persistent session servicesResults the user or a later turn may need: the verdict, the final draftScratch data that should not follow the user into the next turn
temp:The current invocation only; shared by all steps in itFlags, validation results, intermediate values a later step needs this runAnything you must audit later
user: / app:Across sessions for a user or for the appPreferences and configuration a step readsPer-ticket results (they leak across conversations)
ArtifactsVersioned blobs with a reference in stateDocuments, large tool outputs, filesSmall values a template needs directly

The common mistake is putting everything in unprefixed session state: it persists, and a stale value from the previous turn can satisfy a required placeholder, so if this turn's triage writes nothing, the next step silently reads last turn's. Use temp: for per-run keys, or clear them at pipeline start. The full scope model is in ADK Java session and context internals; bulk data belongs in artifacts.

Failure modes

SymptomCauseFix
Step fails with "Context variable not found"Upstream step never produced a final text answer, or the key was misspelledCheck the upstream event stream; keep key names in shared constants
Model output mentions a literal {step.x}Key is not a valid identifier, so it was not substitutedRename keys with underscores
Downstream step gets prose instead of JSONoutputSchema validation failed and the raw string was storedValidate in a beforeAgentCallback; escalate
Step uses last turn's dataRequired key satisfied by a stale session valueUse temp: or reset keys at pipeline start
Token cost grows with every step and turnDefault history includes all prior agents' repliesIncludeContents.NONE on steps fed purely by state
Draft step ignores injected triageConflicting information in history outweighs the instructionCut history for that step; put the key near the end of the instruction
Rejected drafts are still returnedSequential agents run every step regardlessBranch in application code or a guard callback

Operating a sequential pipeline

Make the contract reviewable. Keep every key name in one constants class with a comment giving producer, consumer and expected shape. The broader case for treating key names as an interface is made in advanced ADK Java workflows; with sequential agents it is the whole integration surface.

Test each step in isolation. Seed a session with the keys a step expects (including malformed variants), run just that sub-agent, and assert on what it writes; then run the whole pipeline on recorded tickets.

Observe per step. Log author, state-delta keys and token counts per step. A jump in one step's input tokens usually means history is carrying more than intended.

Bound the cost. Three steps means at least three model calls per message. Steps that do not depend on each other belong in a parallel agent.

What to do next

  1. List every key your pipeline uses, with producer, consumer and shape; rename any that are not valid Java identifiers.
  2. Change dependencies that are truly required from {key?} to {key} so a missing upstream result fails loudly.
  3. Add a beforeAgentCallback at the most fragile boundary that validates structure and escalates on failure.
  4. Move per-run scratch values to temp:, and large payloads to artifacts.
  5. Try IncludeContents.NONE on steps that should depend only on injected state, and compare output quality and tokens.
  6. Write one isolated test per step that seeds state, including a malformed upstream value, and asserts on the key it writes.
Key takeaway: A SequentialAgent runs its steps in order on one shared InvocationContext, so every handoff goes through the session. Write each step's result with outputKey, read it with a required {key} placeholder, and keep key names as plain identifiers, because invalid names are passed through untouched and a failed outputSchema still stores the raw string. Treat conversation history as a side channel rather than a contract, put per-run data in temp:, and guard fragile boundaries with a beforeAgentCallback so a bad step fails visibly.