A single AI agent answering questions impresses nobody anymore. Things get interesting when you need several working together: one that classifies the request, one that researches, a third one that writes, and a human who signs off before anything ships. Coordinating all of that is what’s known as agent orchestration, and it’s exactly what we’re going to build in this tutorial, from scratch and step by step, using Go and version 2.0 of Google’s kit.
If you’ve never heard of orchestration, don’t worry: we start with the concept, diagrams included. If you already have the mental model, jump straight to the tutorial.

What orchestrating AI agents means (and why it matters)
An AI agent is, at its core, an LLM with instructions and tools: text goes in, it decides what to do, a result comes out. MCP servers are becoming the standard way to connect those tools. A single agent works fine for bounded tasks, but it falls short as soon as the problem has multiple phases or needs specialization.
Orchestrating agents means defining how several of them coordinate to complete a job: who goes first, who receives whose output, which decisions open one path or another, what runs in parallel, and where a person steps in. Same idea as an orchestra: every musician plays their part, but someone has to keep the tempo.
The four basic patterns that cover 95% of real systems:
SEQUENTIAL ROUTING PARALLEL HUMAN-IN-THE-LOOP
A ──▶ B ──▶ C A ──▶ type? ──┬─▶ B ┌─▶ B ─┐ A ──▶ [human] ──▶ B
├─▶ C ├─▶ C ─┼─▶ E
└─▶ D └─▶ D ─┘
The catch: if you build this by hand in Go, you end up with goroutines and channels everywhere, a giant switch deciding which agent runs next, and the moment you need a human to approve something mid-run, a homemade “pause and resume” system with Redis and good intentions. It works, but it’s plumbing code that adds zero value to your product.
That’s where version 2.0 of Google’s Agent Development Kit for Go comes in: it ships a graph-based workflow engine as a first-class primitive of the framework. You model the flow as a graph of nodes, declare the edges, and the scheduler handles parallelism, routing, and human-in-the-loop pauses. Let’s build all four patterns from the diagram using the official workflow examples.
Step 1: set up your environment
The v2 module has its own import path, as Go convention demands:
go get google.golang.org/adk/v2
The full API reference lives at pkg.go.dev/google.golang.org/adk/v2. Workflows that only use function nodes need nothing else. Workflows that call an LLM need Gemini credentials: either an API key in GOOGLE_API_KEY, or Vertex AI with Application Default Credentials (gcloud auth application-default login plus GOOGLE_GENAI_USE_VERTEXAI=true, GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION), as documented in the examples README.
To follow along, clone the repo with the examples:
git clone https://github.com/google/adk-go.git
cd adk-go
Step 2: your first sequential workflow
The engine lives in the google.golang.org/adk/v2/workflow package. Every workflow is a directed graph: nodes are units of work and edges define who hands data to whom. Per the examples documentation, there are five node types:
FunctionNode: any Go function, with typed input/output. The workhorse.AgentNode: wraps anLlmAgentso it participates as just another node.ToolNode: wraps atool.Tool.JoinNode: a fan-in barrier; waits for all its predecessors and hands over amap[nodeName]output.DynamicNode: an imperative orchestrator for when you want to decide in Go, at runtime, which children to run.
The simplest example is basic: two FunctionNodes chained with workflow.Chain. The first uppercases the user’s text and the second appends a suffix:
nodeA := workflow.NewFunctionNode("upper", upperFn, nodeConfig)
nodeB := workflow.NewFunctionNode("suffix", suffixFn, nodeConfig)
edges := workflow.Chain(workflow.Start, nodeA, nodeB)
myWorkflow, err := workflowagent.New(workflowagent.Config{
Name: "simple_sequence_workflow",
Description: "Converts string to uppercase and appends a suffix",
Edges: edges,
})
The graph gets packaged as a regular agent via workflowagent.New, so you can serve it with the console launcher that ships with ADK itself without writing a single line of server code. Run it like this:
go run ./examples/workflow/basic/ console
You get a terminal chat to test the flow. No Docker, no UI, nothing — and no API key: basic never touches an LLM.
Step 3: routing, or letting an LLM pick the path
The sequential pattern breaks as soon as the next phase depends on the content: a furious email is not handled like a technical question. That’s where routing comes in, and the routing/llm example teaches the right separation of concerns: the LLM only classifies, and the engine does the routing. None of that “the model returns JSON with the next step and I parse it and pray”.
An AgentNode wrapping a classifier LlmAgent answers with exactly one word (question, exclamation or statement). Then an emitting node turns that word into routes:
func routeByClassification(ctx agent.Context, input any, emit func(*session.Event) error) (any, error) {
category := strings.TrimRight(strings.ToLower(strings.TrimSpace(fmt.Sprint(input))), ".")
if category != "question" && category != "exclamation" && category != "statement" {
category = "statement" // defensive fallback if the LLM goes off-script
}
ev := session.NewEvent(ctx, ctx.InvocationID())
ev.Routes = []string{category}
if err := emit(ev); err != nil {
return nil, err
}
return nil, nil
}
And the edges declare which route activates each destination:
edges := workflow.Concat(
workflow.Chain(workflow.Start, classifyNode, routeNode),
[]workflow.Edge{
{From: routeNode, To: question, Route: workflow.StringRoute("question")},
{From: routeNode, To: statement, Route: workflow.StringRoute("statement")},
{From: routeNode, To: exclamation, Route: workflow.StringRoute("exclamation")},
},
)
There’s StringRoute, IntRoute and MultiRoute for value-based routing. The key detail: if the LLM invents a category, your function normalizes it and falls into the default branch. You stay in control, not the model. One note from the example worth copying: register the wrapped LLM agents in the workflow agent’s SubAgents config, or the runner will log an “Event from an unknown agent” on every turn. This example does need the credentials from step 1.
Step 4: parallelism with fan-out and fan-in
When several tasks don’t depend on each other, running them in series is wasting time. The complex example builds a research pipeline: three researcher agents (renewable energy, electric vehicles, carbon capture) run in parallel, a JoinNode waits for all three, a FunctionNode formats the results, and a synthesis agent writes the final report. The wiring is four lines:
eb := workflow.NewEdgeBuilder()
eb.AddFanOut(workflow.Start, renewableNode, electricVehicleNode, carbonNode)
eb.AddFanIn(gatherNode, renewableNode, electricVehicleNode, carbonNode)
eb.Add(gatherNode, formatNode)
eb.Add(formatNode, synthNode)
The JoinNode (workflow.NewJoinNode("gather")) fires exactly once, after every predecessor has finished, and hands its successor a map[string]any keyed by node name. That’s why the example uses constants for the agent names: they’re also the map keys. No sync.WaitGroup, no results channel, no select. That’s precisely the plumbing the engine takes off your plate.
This is different from building a team of autonomous agents with MiniMax Code, where each agent is a role; here the workflow is a directed graph with explicit edges.
Step 5: human-in-the-loop, pausing so a person can decide
The last pattern, and the one that surprised me most, because it’s usually the first thing you end up building by hand in any serious agent system. In ADK 2.0, pausing a workflow means emitting a RequestInput event and returning ErrNodeInterrupted, as the hitl_simple example does:
ask := workflow.NewEmittingFunctionNode[any, any]("ask_name",
func(ctx agent.Context, _ any, emit func(*session.Event) error) (any, error) {
if err := emit(workflow.NewRequestInputEvent(ctx, session.RequestInput{
InterruptID: "ask_name-" + uuid.NewString(),
Message: "What's your name?",
})); err != nil {
return nil, err
}
return nil, workflow.ErrNodeInterrupted
},
workflow.NodeConfig{},
)
Try it directly:
go run ./examples/workflow/hitl_simple/ console
The console launcher renders the prompt, the person replies, and that reply is delivered to the next node as its typed input. Notice the InterruptID with a fresh UUID per request: the example’s own comment warns that reusing IDs can make the Dev UI treat a later prompt as already answered. A small detail that saves you an afternoon of debugging.
Before letting an LLM make critical decisions, document the system context: an AGENTS.MD file reduces hallucinations and defines behavior rules.
If your case is “a single node that asks and re-runs with the answer” instead of handing off to another node, the hitl_rerun example uses ResumeOrRequestInput for that re-entry pattern. And for approvals mid-pipeline (publish this or not, run this deploy or not), the pattern is the same: an emitting RequestInput node between the node that prepares the action and the one that executes it.
Coming from ADK 1.0? The breaking changes that will hit you
The README-v2 documents the breaking changes. The two that will definitely hit you:
session.NewEventnow requires acontext.Contextas its first argument:session.NewEvent(ctx, ctx.InvocationID()). The event ID and timestamp now come from theplatformpackage, which lets workflow engines produce deterministic, replay-safe events. If you had a helper without a context, add the parameter and pass it down from above; nocontext.Background()in the middle of the chain.ToolContextandCallbackContexthave been merged into a singleagent.Context(PR #945). Your test mocks will break if they implemented the old interface. The comfortable way out: embedagent.StrictContextMockin your fake, override only what the test uses, and stop patching mocks every time the interface grows. Un-overridden methods panic with “not implemented”, which is exactly what you want in a test.
From zero to an orchestrated workflow: recap
You now have the complete mental model: orchestrating means defining a graph of agents and steps, and the four patterns you need are chain, routing, parallel, and human pause. With ADK Go 2.0 each pattern is a few declarative lines, and the scheduler eats the plumbing. If what you need is an autonomous agent that works outside a fixed graph, the optimal OpenClaw configuration is a good complement to this tutorial.
If your system fits in a linear chain, a Chain with three FunctionNodes and you’re done — you don’t need anything else. Where the graph engine starts paying off is the moment any of these three shows up: decisions that depend on an LLM’s output, work that can run in parallel, or a person who needs to approve something before continuing. At that point, the alternative is writing the scheduler yourself, and believe me, you don’t want to maintain a scheduler.
Everything you’ve seen runs locally with a go run, and most of the examples (7 of the 10 in the repo) don’t even need an API key. The order I’d follow to learn it: basic, then hitl_simple, then routing/llm and finally complex. In one afternoon you go from not knowing what orchestration is to having a multi-agent workflow running on your machine.
FAQ
What is AI agent orchestration?
It’s coordinating several AI agents to complete a job: defining execution order, who receives whose output, which decisions open different paths, what runs in parallel, and where a person steps in. It’s usually modeled as a graph of nodes (steps) and edges (flow).
What do I need to follow this tutorial?
Go installed and the google/adk-go repo cloned. The basic, hitl_simple, routing/string, routing/int, hitl_rerun and dynamic examples need no API key. For routing/llm and complex you need Gemini credentials (GOOGLE_API_KEY) or Vertex AI.
How does human-in-the-loop work in ADK Go 2.0?
An emitting node sends a RequestInput event and returns workflow.ErrNodeInterrupted. The launcher renders the prompt, the person replies, and the typed reply is delivered to the next node as input. This models approvals or interactive questions.


What do you think?
Leave your opinion, question or suggestion. Comments are synced with GitHub Discussions .