Multi-LLM Agent Orchestration in Go
Orchestrate Gemini, Claude, and OpenAI agents in a dynamic Go pipeline.
Introduction
30 Second Summary
Some of the biggest oil discoveries in Texas history came from people who knew how to read old land records. Cross-referencing 19th-century land patents with modern well production data is still how wildcatters identify overlooked mineral rights today.
In this project, you will build a command-line oil speculation engine that orchestrates three AI agents (each powered by a different LLM provider) to analyze Texas land patents and rank them by oil potential using ADK Go 2.0's dynamic graph orchestration.
What You'll Build
You type a Texas county name, and a multi-agent pipeline fans out research across three LLM providers, waits for all of them to finish, then synthesizes their findings into a scored speculation report ranking land patents by oil potential.
By the end of this project, you'll have:
- A working multi-agent pipeline where you type any Texas county and get an AI-generated speculation report synthesized from three different LLM providers (Gemini, Claude, OpenAI) working in concert.
- A DynamicNode orchestrator that decides at runtime whether to fan out both research agents or run only one, based on what data exists for the county you entered.
- A fan-out/fan-in coordination pattern where you can watch three independent LLM calls fire in parallel and converge at a barrier before producing the final report.
- Secret Mission: Add a human-in-the-loop approval gate so the pipeline pauses for your review before generating the final speculation ranking.
Are there any prerequisites?
This project assumes Go proficiency and requires three API keys: Google Gemini, Anthropic Claude, and OpenAI. You should be comfortable reading and writing Go code, including working with interfaces and error handling.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This project brings together three different AI providers into a single coordinated pipeline, so understanding the full picture upfront will help every step click into place.
Set Up the Project
Before any agent code can compile, you need the ADK Go v2 framework and three LLM provider adapters installed in a Go module.
In this step, you'll create the project directory, install the core ADK dependency plus adapters for Gemini, Claude, and OpenAI, and set up sample data files that represent Texas land patents and oil well production records.
In this step, get ready to:
- Initialize the Go module and install ADK Go v2.
- Install the Anthropic and OpenAI LLM adapters.
- Create sample data files and verify the environment.
Initialize Go module and install ADK Go v2
ADK Go v2 is the orchestration framework that lets you compose agents into workflow graphs. You need a Go module to manage the dependency.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir oil-speculation-engine
cd oil-speculation-engine
- Initialize a new Go module by running:
go mod init oil-speculation-engine
- Confirm the module was created by running:
ls
You should see go.mod listed in the output.
- Install the core ADK Go v2 dependency by running:
go get google.golang.org/adk/v2
You should see download output as Go fetches the ADK module and its transitive dependencies.
What does this install?
ADK Go v2 provides the workflow engine, agent interfaces, launcher, and session management primitives you'll use to wire agents together. The google.golang.org/adk/v2 module path reflects the v2 major version released June 30, 2026.
Seeing a network error?
Make sure you have internet access and that your Go proxy settings are not blocking downloads. Run go env GOPROXY to check your proxy setting. The default value https://proxy.golang.org,direct should work.
Help me troubleshoot my Go proxy and network settings
Install multi-provider LLM adapters
Each research agent in the pipeline uses a different LLM provider. You need the Gemini client library, the Anthropic community adapter, and the OpenAI community adapter.
- Install the Google GenAI client library (used for Gemini model creation) by running:
go get google.golang.org/genai
- Install the Anthropic adapter for ADK Go v2 by running:
go get github.com/Alcova-AI/adk-anthropic-go/v2
- Install the OpenAI-compatible adapter by running:
go get github.com/ieshan/adk-go-pkg
- Verify all four dependencies are listed by running:
cat go.mod
You should see google.golang.org/adk/v2, google.golang.org/genai, github.com/Alcova-AI/adk-anthropic-go/v2, and github.com/ieshan/adk-go-pkg in the require block.
Why three separate adapters?
ADK Go v2 defines a unified model.LLM interface. Gemini has a built-in adapter in the ADK module itself. Claude and OpenAI use community adapters that implement the same interface, so your workflow code stays identical regardless of which provider backs an agent.
Seeing a 404 or module not found?
The community adapters require their exact module paths. Double-check for typos in the go get commands. The Anthropic adapter must include /v2 at the end of its path.
Help me fix my module not found error
Create sample data files and verify environment
The agents need structured data to analyze. You'll create JSON files representing Texas General Land Office patent records and Railroad Commission well production data for testing.
- Create the data directory by running:
mkdir data
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type the name of your code editor (e.g. VS Code) and press Enter to open it.
- Open the oil-speculation-engine folder from your Desktop in the editor.
- Inside the data folder, create a new file called patents.json and paste in this content:
[
{"county": "WEBB", "patentee": "Justo Trevino", "abstract_num": "A-380", "class": "Spanish Grant", "acres": 4428, "patent_date": "1835-03-15", "survey_name": "Porcion 15", "file_num": "SP-0012"},
{"county": "WEBB", "patentee": "Basilio Benavides", "abstract_num": "A-42", "class": "Spanish Grant", "acres": 8856, "patent_date": "1767-11-20", "survey_name": "Porcion 42", "file_num": "SP-0042"},
{"county": "WEBB", "patentee": "Texas and Pacific Railway Co", "abstract_num": "A-512", "class": "Railroad", "acres": 640, "patent_date": "1882-06-01", "survey_name": "Section 12 Block 1", "file_num": "RR-0512"},
{"county": "WEBB", "patentee": "State of Texas PSF", "abstract_num": "A-601", "class": "School Land", "acres": 640, "patent_date": "1905-09-10", "survey_name": "Section 45 Block 2", "file_num": "SL-0601"},
{"county": "WEBB", "patentee": "Manuel Garcia", "abstract_num": "A-155", "class": "Mexican Grant", "acres": 2214, "patent_date": "1840-08-22", "survey_name": "Porcion 8", "file_num": "MX-0155"},
{"county": "WEBB", "patentee": "International Great Northern RR", "abstract_num": "A-720", "class": "Railroad", "acres": 320, "patent_date": "1878-02-14", "survey_name": "Section 7 Block 3", "file_num": "RR-0720"},
{"county": "WEBB", "patentee": "J.M. Ramirez", "abstract_num": "A-290", "class": "Headright", "acres": 1476, "patent_date": "1870-04-30", "survey_name": "Survey 290", "file_num": "HR-0290"},
{"county": "KINNEY", "patentee": "John Twohig", "abstract_num": "A-88", "class": "Headright", "acres": 1280, "patent_date": "1852-07-10", "survey_name": "Survey 88", "file_num": "HR-0088"},
{"county": "KINNEY", "patentee": "State of Texas PSF", "abstract_num": "A-200", "class": "School Land", "acres": 640, "patent_date": "1900-03-01", "survey_name": "Section 20 Block 5", "file_num": "SL-0200"},
{"county": "KINNEY", "patentee": "Galveston Harrisburg San Antonio Ry", "abstract_num": "A-310", "class": "Railroad", "acres": 640, "patent_date": "1876-11-05", "survey_name": "Section 31 Block 7", "file_num": "RR-0310"}
]
What is this data?
These records mirror the Texas General Land Office patent database fields: county, patentee, abstract number, land class, acreage, patent date, survey name, and file number. The dataset includes both Webb County (full data coverage) and Kinney County (patents only, no well data) so you can test conditional routing later.
- Create a second file in the data folder called wells.json and paste in this content:
[
{"county": "WEBB", "api_num": "479-30001", "well_name": "Trevino Ranch #1", "operator": "Laredo Energy Inc", "survey_name": "Porcion 15", "abstract_num": "A-380", "completion_date": "2018-03-15", "well_type": "OIL", "production_bbl": 45200.5, "depth_ft": 7850, "status": "ACTIVE"},
{"county": "WEBB", "api_num": "479-30045", "well_name": "Benavides Unit #3", "operator": "Eagle Ford Operating", "survey_name": "Porcion 42", "abstract_num": "A-42", "completion_date": "2019-07-22", "well_type": "OIL", "production_bbl": 128750.0, "depth_ft": 8200, "status": "ACTIVE"},
{"county": "WEBB", "api_num": "479-30102", "well_name": "Garcia Minerals #1", "operator": "Laredo Energy Inc", "survey_name": "Porcion 8", "abstract_num": "A-155", "completion_date": "2020-01-10", "well_type": "OIL", "production_bbl": 67300.25, "depth_ft": 7920, "status": "ACTIVE"},
{"county": "WEBB", "api_num": "479-30150", "well_name": "PSF Section 45 #2", "operator": "State Mineral Development Corp", "survey_name": "Section 45 Block 2", "abstract_num": "A-601", "completion_date": "2017-11-30", "well_type": "GAS", "production_bbl": 0, "depth_ft": 9100, "status": "ACTIVE"},
{"county": "WEBB", "api_num": "479-30201", "well_name": "Railroad Sec12 #1", "operator": "Permian Resources", "survey_name": "Section 12 Block 1", "abstract_num": "A-512", "completion_date": "2021-05-18", "well_type": "OIL", "production_bbl": 31400.0, "depth_ft": 8050, "status": "ACTIVE"},
{"county": "WEBB", "api_num": "479-30250", "well_name": "Ramirez Survey #1", "operator": "Eagle Ford Operating", "survey_name": "Survey 290", "abstract_num": "A-290", "completion_date": "2022-09-01", "well_type": "OIL", "production_bbl": 15600.75, "depth_ft": 7600, "status": "ACTIVE"},
{"county": "WEBB", "api_num": "479-25010", "well_name": "Old Trevino #1", "operator": "Historic Texas Oil Co", "survey_name": "Porcion 15", "abstract_num": "A-380", "completion_date": "1985-06-20", "well_type": "OIL", "production_bbl": 250000.0, "depth_ft": 5200, "status": "PLUGGED"}
]
What is this data?
These records mirror the Texas Railroad Commission well production database fields: API number, well name, operator, survey, completion date, well type, cumulative production, depth, and status. Notice the well records share abstract_num values with the patents. That's how the synthesizer agent will cross-reference land ownership history with actual oil production.
- Save both files, then confirm they exist by running this in your terminal:
ls data
You should see patents.json and wells.json listed.
- Export your three API keys as environment variables by running these commands (replace each placeholder with your actual key):
export GOOGLE_API_KEY=your-google-api-key-here
export ANTHROPIC_API_KEY=your-anthropic-api-key-here
export OPENAI_API_KEY=your-openai-api-key-here
Where do I get these keys?
You need API keys from three providers: Google AI Studio for Gemini, the Anthropic console for Claude, and the OpenAI platform for GPT-4o. Each provider requires a free account and generates a key from their developer dashboard.
These exports only last for your current terminal session. If you close your terminal, you'll need to re-export them.
- Verify the keys are set by running:
echo $GOOGLE_API_KEY
You should see your key printed back. If you see a blank line, re-run the export command for that key.
Before you run the final check, what do you expect go build ./... to produce when there are no .go source files yet?
- Verify the module is in a clean state by running:
go build ./...
You should see no output at all. Silence means success. Go found no compile errors in the module and all dependencies resolved correctly.
Seeing an error about missing .go files?
If you see an error mentioning "no Go files in" followed by a directory path, make sure you're running go build ./... (with the three dots wildcard), not go build . (single dot). The wildcard pattern skips directories with no source files, while the single dot requires at least one.
Help me diagnose my go build error
Your module is initialized, all three LLM adapters are installed, and the sample datasets are ready. Next up, you'll write your first agent and see it analyze patent data using Gemini.
Build a Single-Agent Baseline
Your project has the module, dependencies, and sample data ready. Before wiring three agents into a fan-out pipeline, you need proof that one agent works end-to-end.
In this step you will build a single ADK Go workflow with a Gemini-backed agent that reads land patent data and returns a mineral-rights analysis. If you skip this and jump straight to three agents, a failure could live anywhere in the stack.
In this step, get ready to:
- Define Go structs for patent and well records.
- Create a FunctionNode that loads and formats patent data.
- Wire a Gemini LlmAgent through a workflow graph and run it from the console.
Create the Gemini-backed land patent extractor agent
An LlmAgent in ADK Go needs three things: a model connection, an instruction describing its expertise, and Go types that represent its input data. Start with the types file.
- Create a new file called types.go in your oil-speculation-engine folder and paste the following code:
package main
// PatentRecord maps to a single entry in data/patents.json
type PatentRecord struct {
County string `json:"county"`
Patentee string `json:"patentee"`
AbstractNum string `json:"abstract_num"`
Class string `json:"class"`
Acres int `json:"acres"`
PatentDate string `json:"patent_date"`
SurveyName string `json:"survey_name"`
FileNum string `json:"file_num"`
}
// WellRecord maps to a single entry in data/wells.json
type WellRecord struct {
County string `json:"county"`
APINum string `json:"api_num"`
WellName string `json:"well_name"`
Operator string `json:"operator"`
SurveyName string `json:"survey_name"`
AbstractNum string `json:"abstract_num"`
CompletionDate string `json:"completion_date"`
WellType string `json:"well_type"`
ProductionBBL float64 `json:"production_bbl"`
DepthFt int `json:"depth_ft"`
Status string `json:"status"`
}
What does this code do?
PatentRecord holds fields from the Texas General Land Office patent database: county, patentee name, classification (Spanish Grant, Railroad, School Land, etc.), acreage, and patent date.
WellRecord holds fields modeled after the Railroad Commission of Texas production data: API number, operator, well type, cumulative production in barrels, and depth.
- Save types.go.
- Confirm the file exists by running:
ls types.go
You should see types.go listed in the output.
- Create a new file called main.go in the same folder and paste the following package declaration, imports, and agent-name constants:
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"google.golang.org/genai"
"google.golang.org/adk/v2/agent"
"google.golang.org/adk/v2/agent/llmagent"
"google.golang.org/adk/v2/agent/workflowagent"
"google.golang.org/adk/v2/cmd/launcher"
"google.golang.org/adk/v2/cmd/launcher/full"
"google.golang.org/adk/v2/model/gemini"
"google.golang.org/adk/v2/workflow"
)
const (
patentAgentName = "LandPatentExtractor"
productionAgentName = "ProductionCorrelator"
synthesisAgentName = "SpeculationSynthesizer"
)
What does this code do?
The import block pulls in the ADK Go v2 packages: llmagent for creating LLM-backed agents, workflowagent for graph orchestration, workflow for node primitives, and gemini for the Gemini model adapter.
The three constants name the agents you will build across this project. Declaring them now avoids magic strings later.
- Save main.go.
- Confirm both files exist by running:
ls *.go
You should see main.go and types.go in the output.
Create a FunctionNode that loads patent data
A FunctionNode is a workflow node that runs plain Go code (no LLM call). You will use one to read the JSON, filter by county, and format the records into a prompt the agent can analyze.
- In main.go, add the agent instruction and the patent data loader below the const block:
const patentInstruction = `You are a Texas land patent analyst specializing in mineral rights.
Analyze the provided land patent records and identify patents with high mineral-rights potential based on:
1. Grant era: Pre-1866 Spanish/Mexican grants (minerals reserved by state) vs post-1866 patents (minerals conveyed to landowner).
2. Acreage: Larger tracts have higher exploration potential.
3. Survey classification: School lands, university lands, and PSF lands have state mineral interests.
4. Geographic clustering: Multiple patents in the same survey area suggest systematic settlement patterns near resources.
Output a structured JSON array of the top 5 patents ranked by mineral potential, with fields: patentee, acres, patent_date, class, mineral_score (1-10), reasoning.`
func loadPatentData(county string) ([]PatentRecord, error) {
data, err := os.ReadFile("data/patents.json")
if err != nil {
return nil, fmt.Errorf("reading patents.json: %w", err)
}
var records []PatentRecord
if err := json.Unmarshal(data, &records); err != nil {
return nil, fmt.Errorf("parsing patents.json: %w", err)
}
var filtered []PatentRecord
for _, r := range records {
if strings.EqualFold(r.County, county) {
filtered = append(filtered, r)
}
}
return filtered, nil
}
What does this code do?
patentInstruction tells the Gemini agent exactly how to evaluate patents. It references the 1866 Texas Constitution boundary. Pre-1866 Spanish and Mexican grants reserved minerals for the state. Post-1866 patents conveyed minerals to the landowner.
loadPatentData reads the JSON file from disk, deserializes it into PatentRecord slices, and filters by county using a case-insensitive comparison.
- Save main.go.
- Below loadPatentData, add the well production loader and the prompt formatter that the FunctionNode will execute:
func loadProductionData(county string) ([]WellRecord, error) {
data, err := os.ReadFile("data/wells.json")
if err != nil {
return nil, fmt.Errorf("reading wells.json: %w", err)
}
var records []WellRecord
if err := json.Unmarshal(data, &records); err != nil {
return nil, fmt.Errorf("parsing wells.json: %w", err)
}
var filtered []WellRecord
for _, r := range records {
if strings.EqualFold(r.County, county) {
filtered = append(filtered, r)
}
}
return filtered, nil
}
func formatPatentPrompt(_ agent.Context, county string) (string, error) {
patents, err := loadPatentData(county)
if err != nil {
return "", err
}
if len(patents) == 0 {
return fmt.Sprintf("No land patent records found for %s County.", county), nil
}
data, _ := json.MarshalIndent(patents, "", " ")
return fmt.Sprintf("Analyze these %d land patent records for %s County, Texas:\n\n%s", len(patents), county, string(data)), nil
}
What does this code do?
loadProductionData mirrors the patent loader but targets well records. You will use it in Step 3 when you add the Claude correlator agent.
formatPatentPrompt is the function that ADK will wrap as a FunctionNode. Its signature func(_ agent.Context, county string) (string, error) matches what workflow.NewFunctionNode expects. It loads patents, serializes them as indented JSON, and returns a prompt string the agent will analyze.
- Save main.go.
Wire the single-agent workflow and run
You have the instruction and the data-loading function. Now you need to connect them inside a workflow graph: create the Gemini model, build the agent, wrap both the data loader and the agent as graph nodes, chain them, and launch.
- Below formatPatentPrompt, add the first half of the buildPipeline function that creates the Gemini model and the patent agent:
func buildPipeline(ctx context.Context) (agent.Agent, error) {
// Connect to Gemini using the API key from your environment
geminiModel, err := gemini.NewModel(ctx, "gemini-2.5-flash", &genai.ClientConfig{
APIKey: os.Getenv("GOOGLE_API_KEY"),
})
if err != nil {
return nil, fmt.Errorf("creating gemini model: %w", err)
}
// Create the LLM agent with the patent analysis instruction
patentAgent, err := llmagent.New(llmagent.Config{
Name: patentAgentName,
Model: geminiModel,
Description: "Analyzes Texas land patent records for mineral rights potential.",
Instruction: patentInstruction,
})
if err != nil {
return nil, fmt.Errorf("creating patent agent: %w", err)
}
What does this code do?
gemini.NewModel connects to the Gemini API using your GOOGLE_API_KEY environment variable. The model ID gemini-2.5-flash gives fast structured output.
llmagent.New creates a single-turn LLM agent. The Instruction field is the system prompt that tells Gemini to act as a patent analyst.
- Directly below (still inside buildPipeline), add the graph wiring that chains the data loader to the agent and wraps everything in a workflow agent:
// Wrap formatPatentPrompt as a FunctionNode in the graph
patentDataNode := workflow.NewFunctionNode("load_patents", formatPatentPrompt, workflow.NodeConfig{})
patentAgentNode, err := workflow.NewAgentNode(patentAgent, workflow.NodeConfig{})
if err != nil {
return nil, fmt.Errorf("creating patent agent node: %w", err)
}
// Chain: Start -> load data -> analyze with Gemini
wa, err := workflowagent.New(workflowagent.Config{
Name: "oil_speculation_engine",
Description: "Multi-agent pipeline for Texas land patent oil speculation analysis.",
Edges: workflow.Chain(workflow.Start, patentDataNode, patentAgentNode),
SubAgents: []agent.Agent{patentAgent},
})
if err != nil {
return nil, fmt.Errorf("creating workflow agent: %w", err)
}
return wa, nil
}
What does this code do?
workflow.NewFunctionNode wraps your formatPatentPrompt function as a graph node named load_patents. The framework will call it with whatever the user typed as input (the county name).
workflow.Chain(workflow.Start, patentDataNode, patentAgentNode) creates a sequential edge list: user input flows into the data loader, its string output flows into the Gemini agent, and the agent's response flows back to the user.
workflowagent.New wraps the entire graph as a standard agent.Agent that the ADK launcher can serve.
- Below buildPipeline, add the main function that validates API keys and launches the console:
func main() {
ctx := context.Background()
// Verify all three API keys are set
for _, env := range []string{"GOOGLE_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"} {
if os.Getenv(env) == "") {
log.Fatalf("%s environment variable is required", env)
}
}
rootAgent, err := buildPipeline(ctx)
if err != nil {
log.Fatalf("failed to build pipeline: %v", err)
}
log.Printf("Oil Speculation Engine ready. Enter a Texas county name to analyze.")
l := full.NewLauncher()
if err := l.Execute(ctx, &launcher.Config{
AgentLoader: agent.NewSingleLoader(rootAgent),
}, os.Args[1:]); err != nil {
log.Fatalf("Run failed: %v\n\n%s", err, l.CommandLineSyntax())
}
}
What does this code do?
The loop checks that all three provider keys are exported. Even though this step only uses Gemini, the check ensures you will not hit a confusing error in later steps.
full.NewLauncher() provides a console-mode interactive loop. When you pass console as a CLI argument, it reads your input, sends it through the workflow graph, and prints the agent's response.
- Save main.go.
- Compile the project to confirm there are no syntax errors by running:
go build ./...
You should see no output at all. A clean return means the code compiles successfully.
Seeing compile errors?
If you see undefined: PatentRecord, confirm that types.go starts with package main (not package types). Both files must share the same package name.
If you see imported and not used, double-check that the main function and all helper functions are present. Every import is used somewhere in the final file.
help me fix my Go compile errors in the oil speculation engine
Before you run the pipeline, what do you think will happen when you type a county name? Will you see raw JSON, a formatted report, or an error?
- Launch the console by running:
go run . console
- When the prompt appears, type WEBB and press Enter.
You should see Gemini return a structured JSON array ranking the Webb County patents by mineral potential, with scores and reasoning for each. The agent is analyzing all 7 Webb County records from your sample data.
- Press Ctrl+C to exit the console launcher.
Not seeing any output?
If the program exits immediately with a log message about a missing environment variable, confirm you exported GOOGLE_API_KEY, ANTHROPIC_API_KEY, and OPENAI_API_KEY in your current terminal session.
If you see a network error or authentication failure, verify your GOOGLE_API_KEY is a valid Gemini API key (not a GCP service account key).
help me debug why my ADK Go agent isn't returning results
✔️ Awesome, I've got everything!
Great. Confirm you have saved both types.go and main.go and that the console produced a patent analysis.
ⓧ I'd like to double check the full code
Your types.go should look exactly like this:
package main
type PatentRecord struct {
County string `json:"county"`
Patentee string `json:"patentee"`
AbstractNum string `json:"abstract_num"`
Class string `json:"class"`
Acres int `json:"acres"`
PatentDate string `json:"patent_date"`
SurveyName string `json:"survey_name"`
FileNum string `json:"file_num"`
}
type WellRecord struct {
County string `json:"county"`
APINum string `json:"api_num"`
WellName string `json:"well_name"`
Operator string `json:"operator"`
SurveyName string `json:"survey_name"`
AbstractNum string `json:"abstract_num"`
CompletionDate string `json:"completion_date"`
WellType string `json:"well_type"`
ProductionBBL float64 `json:"production_bbl"`
DepthFt int `json:"depth_ft"`
Status string `json:"status"`
}
For main.go, scroll through and confirm these sections appear in order:
- Package declaration + import block (6 standard library, genai, 6 ADK packages).
- The const block with patentAgentName, productionAgentName, synthesisAgentName.
- patentInstruction raw string constant.
- loadPatentData function.
- loadProductionData function.
- formatPatentPrompt function.
- buildPipeline function (creates model, agent, nodes, workflowagent).
- main function (env check, build pipeline, launch console).
Your single-agent baseline is working. Next up, you will add a second agent backed by Claude to correlate the patent findings with actual oil well production data.
Add the Claude Production Correlator as a Parallel Agent
Your single-agent baseline proved that ADK Go's workflow engine can load data, feed it to a Gemini agent, and return structured analysis. But analyzing land patents in isolation tells you nothing about where oil has actually been found nearby.
In this step, you'll add a second agent backed by Anthropic Claude that analyzes real well production data for geological patterns. You'll wire both agents to launch in parallel from the workflow's start node using the EdgeBuilder fan-out primitive.
In this step, get ready to:
- Create a Claude-backed production correlator agent.
- Add a FunctionNode that loads well production data.
- Wire fan-out to launch both research paths in parallel.
Create the Claude-backed production correlator agent
The production correlator needs its own system instruction telling Claude how to interpret well data. You also need the Anthropic adapter import so Go can create the Claude model.
- In main.go, find the closing parenthesis of the import block.
- Add a new import group for the Anthropic adapter at the bottom of the import block, just before the closing ):
import (
// ... existing imports stay the same ...
adkanthropic "github.com/Alcova-AI/adk-anthropic-go/v2"
)
- Below the patentInstruction const, add the production correlator's system instruction:
const productionInstruction = `You are a petroleum geologist analyzing Texas oil well production data.
Given well production records for a county, identify geological patterns that indicate underdeveloped areas:
1. Production decline curves: Wells with slow decline suggest large reservoirs.
2. Spatial clustering: Gaps between productive wells may indicate unexplored zones.
3. Depth patterns: Consistent production depths suggest continuous formations.
4. Operator activity: Areas with recent permits near old wells suggest renewed interest.
Output a structured JSON with fields: high_potential_zones (array of {survey_area, avg_production_bbl, well_count, trend, confidence}), geological_notes, recommended_focus_areas.`
What does this instruction do?
The production correlator has a completely different analytical lens than the patent agent. It looks for geological signals in well data: decline curves that suggest large reservoirs, spatial gaps that hint at unexplored zones, and operator activity indicating renewed interest.
By giving each agent a focused specialization, you get richer analysis than one generalist agent could produce.
- Verify the const compiles by running:
go build ./...
You should see a clean build with no output.
Seeing an unused import error?
If you see an error about adkanthropic being imported and not used, that is expected at this point. You will use it in the next chunk. Comment out the import line temporarily or continue to the next code block which adds the usage.
Help me fix this unused import error
Now add the Claude model and production agent inside buildPipeline.
- In the buildPipeline function, find the closing brace of the patentAgent error check. Add the Claude model and production agent below it:
claudeModel, err := adkanthropic.NewModel(ctx, "claude-sonnet-4-20250514", &adkanthropic.Config{
APIKey: os.Getenv("ANTHROPIC_API_KEY"),
})
if err != nil {
return nil, fmt.Errorf("creating claude model: %w", err)
}
productionAgent, err := llmagent.New(llmagent.Config{
Name: productionAgentName,
Model: claudeModel,
Description: "Correlates oil well production data with geological patterns.",
Instruction: productionInstruction,
})
if err != nil {
return nil, fmt.Errorf("creating production agent: %w", err)
}
What does this code do?
adkanthropic.NewModel creates a model.LLM-compatible wrapper around Anthropic's API. It takes the same ctx and returns the same interface as gemini.NewModel.
This is the model.LLM interface polymorphism in action. The workflow engine treats both agents identically, regardless of which LLM backs them.
- Save main.go and verify the build:
go build ./...
You should see a clean build with no errors.
Build failing with model not found?
Make sure you spelled the model name exactly as claude-sonnet-4-20250514 in the adkanthropic.NewModel call.
Also check that your ANTHROPIC_API_KEY environment variable is still exported. Run echo $ANTHROPIC_API_KEY to confirm.
Help me debug my Claude model build error
Add a second FunctionNode for well production data
The production agent needs data fed to it the same way the patent agent does. You need a FunctionNode that reads the well JSON, filters by county, and formats a prompt.
- In main.go, find the formatPatentPrompt function. Add formatProductionPrompt directly below it:
func formatProductionPrompt(_ agent.Context, county string) (string, error) {
wells, err := loadProductionData(county)
if err != nil {
return "", err
}
if len(wells) == 0 {
return fmt.Sprintf("No production data found for %s County.", county), nil
}
data, _ := json.MarshalIndent(wells, "", " ")
return fmt.Sprintf("Analyze these %d well production records for %s County, Texas:\n\n%s", len(wells), county, string(data)), nil
}
What does this function do?
This mirrors formatPatentPrompt but for well production data. It reuses the loadProductionData helper you wrote in Step 2, filters by county, and formats the records into a prompt string the production agent can analyze.
- Save main.go and verify:
go build ./...
Clean build confirms the new function signature matches what NewFunctionNode expects.
Wire fan-out from Start to both agent pipelines
Right now the workflow chains patent data loading into the patent agent sequentially. To run both research agents in parallel, you need to replace that chain with a fan-out graph that launches both pipelines from the Start node simultaneously.
- In buildPipeline, find the existing node declarations and workflowagent.New block. Replace everything from patentDataNode := through the end of the workflowagent.New error check with this:
patentDataNode := workflow.NewFunctionNode("load_patents", formatPatentPrompt, workflow.NodeConfig{})
productionDataNode := workflow.NewFunctionNode("load_production", formatProductionPrompt, workflow.NodeConfig{})
patentAgentNode, err := workflow.NewAgentNode(patentAgent, workflow.NodeConfig{})
if err != nil {
return nil, fmt.Errorf("creating patent agent node: %w", err)
}
productionAgentNode, err := workflow.NewAgentNode(productionAgent, workflow.NodeConfig{})
if err != nil {
return nil, fmt.Errorf("creating production agent node: %w", err)
}
wa, err := workflowagent.New(workflowagent.Config{
Name: "oil_speculation_engine",
Description: "Multi-agent pipeline for Texas land patent oil speculation analysis.",
Edges: workflow.NewEdgeBuilder().
AddFanOut(workflow.Start, []workflow.Node{
workflow.Chain(patentDataNode, patentAgentNode),
workflow.Chain(productionDataNode, productionAgentNode),
}).Build(),
SubAgents: []agent.Agent{patentAgent, productionAgent},
})
if err != nil {
return nil, fmt.Errorf("creating workflow agent: %w", err)
}
What does this wiring do?
workflow.NewEdgeBuilder().AddFanOut tells the scheduler to dispatch both research paths concurrently from workflow.Start.
Each path is its own workflow.Chain (data loader into agent node). The scheduler runs the patent pipeline and the production pipeline at the same time, without either waiting for the other.
SubAgents registers both LLM agents so the workflow engine can manage their sessions.
- Save main.go and verify the build compiles:
go build ./...
A clean build confirms the fan-out graph structure is valid.
Seeing undefined: productionDataNode?
Make sure you deleted the old patentDataNode and patentAgentNode lines before pasting the new block. Duplicate declarations cause redefinition errors in Go.
Help me find the duplicate declarations
Before you run this, think about what will happen. Both agents will execute in parallel and produce output. But where does that output go?
- Run the program and enter a county name:
go run . console
- When prompted, type WEBB and press Enter.
You'll see interleaved log output showing both agents running concurrently. But the final output is garbled or incomplete. One agent's response might appear while the other is missing entirely, or the program may exit without producing a coherent combined result.
Why is the output broken?
The fan-out successfully launches both agents in parallel. You can see from the logs that Gemini and Claude both receive their data and produce responses.
The problem is there is no fan-in barrier collecting their results. Without a JoinNode, the workflow engine has no way to wait for both agents, gather their outputs into a single structure, and pipe the combined findings to a synthesizer. That gap is exactly what the next step fixes.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go and that go build ./... passes clean.
ⓧ I'd like to double check the full code
Your main.go is now longer than 30 lines. Scroll through and confirm these sections exist, in order:
- Import block with adkanthropic "github.com/Alcova-AI/adk-anthropic-go/v2" at the end.
- patentInstruction const followed by productionInstruction const.
- loadPatentData and loadProductionData functions.
- formatPatentPrompt followed by formatProductionPrompt.
- buildPipeline creating geminiModel, then claudeModel, then patentAgent, then productionAgent.
- Node declarations: patentDataNode, productionDataNode, patentAgentNode, productionAgentNode.
- workflowagent.New with AddFanOut wiring and both agents in SubAgents.
- main function unchanged from before.
Both agents successfully run in parallel, proving the fan-out works and your multi-provider composition is solid. Next up, you'll add a JoinNode barrier to collect both agents' outputs and feed them into an OpenAI-backed synthesizer for the final speculation report.
Fan-In with JoinNode and Add the OpenAI Synthesizer
In the previous step, you proved both research agents run concurrently. But their outputs currently vanish into the void.
A fan-out without a fan-in is like dispatching two field researchers who never file their reports. In this step, you'll add a JoinNode barrier that collects both agent outputs into a single map, then pipe that combined intelligence into a third agent backed by OpenAI to produce the final speculation report.
In this step, get ready to:
- Add a JoinNode barrier and format function that collects parallel research outputs.
- Create an OpenAI-backed synthesis agent that produces a scored ranking report.
- Wire the complete fan-out/fan-in graph and run the full pipeline.
Add JoinNode and format function
The JoinNode is a barrier that waits for every predecessor to finish, then collects their outputs into a map[string]any keyed by node name. The format function will reshape that map into a prompt for the synthesis agent.
First you need the instruction that tells the synthesizer what to produce.
- In main.go, find the productionInstruction constant. Add the following new constant directly below it:
const synthesisInstruction = `You are an oil speculation analyst combining land patent research with production data.
Given patent analysis and production correlation findings, produce a final speculation report:
1. Cross-reference high mineral-potential patents with nearby production zones.
2. Score each patent 1-100 for speculation value based on combined evidence.
3. Flag any patents where mineral rights may still be available (post-1866, not in state mineral inventory).
Output format:
## Oil Speculation Report: [County] County, Texas
### Executive Summary
[2-3 sentences on overall opportunity]
### Ranked Opportunities
| Rank | Patentee | Acres | Speculation Score | Key Factors |
[table rows]
### Risk Factors
[bullet list]
### Recommended Next Steps
[numbered list]`
What does this instruction do?
This prompt template tells the synthesis agent to cross-reference patent findings with production data, score each patent from 1 to 100, and flag patents where mineral rights might still be acquirable.
The structured output format (markdown table, risk factors, next steps) ensures the final report is actionable rather than a wall of unstructured text.
- Save main.go.
Now add the function that reshapes the joined map into a synthesis prompt.
- In main.go, find the formatProductionPrompt function. Add the following new function directly below it:
func formatSynthesisPrompt(_ agent.Context, gathered map[string]any) (string, error) {
var sb strings.Builder
sb.WriteString("Combine these research findings into a speculation report:\n\n")
sb.WriteString("## Land Patent Analysis\n")
if v, ok := gathered[patentAgentName]; ok && v != nil {
sb.WriteString(fmt.Sprint(v))
} else {
sb.WriteString("(no patent analysis available)")
}
sb.WriteString("\n\n## Production Correlation\n")
if v, ok := gathered[productionAgentName]; ok && v != nil {
sb.WriteString(fmt.Sprint(v))
} else {
sb.WriteString("(no production data available)")
}
return sb.String(), nil
}
What does this function do?
The gathered parameter is the map[string]any that the JoinNode produces. Each key is the name of a predecessor node. The function checks for the patent and production agent outputs by name, formats them into labeled sections, and returns the combined string as a prompt.
If either agent produced no output, the function inserts a fallback placeholder so the synthesizer can still produce a partial report.
- Save main.go and confirm compilation by running:
go build ./...
You should see no output, meaning the build succeeded with no errors.
Seeing a compile error?
- Check that synthesisInstruction uses a backtick (`) for the opening and closing of the raw string literal, not regular double quotes.
- Ensure formatSynthesisPrompt is placed outside of any other function at the package level.
- Confirm the function signature uses gathered map[string]any (not map[string]interface{}). Go 1.25 supports the any alias.
help me fix a compile error after adding formatSynthesisPrompt
Create the OpenAI-backed synthesis agent
The synthesis agent is the third LLM provider in the pipeline. It takes the combined research findings from both researcher agents and produces the final scored report.
- In main.go, find your import block. Locate the adkanthropic import line:
adkanthropic "github.com/Alcova-AI/adk-anthropic-go/v2"
- Add the OpenAI adapter import directly below it so both external adapters are grouped together:
adkanthropic "github.com/Alcova-AI/adk-anthropic-go/v2"
"github.com/ieshan/adk-go-pkg/model/openai"
Now add the OpenAI model and synthesis agent inside buildPipeline.
- In the buildPipeline function, find where claudeModel is created. Add the following code directly after the claudeModel error check:
openaiModel, err := openai.New(openai.Config{
Model: "gpt-4o",
APIKey: os.Getenv("OPENAI_API_KEY"),
})
if err != nil {
return nil, fmt.Errorf("creating openai model: %w", err)
}
What does this code do?
The openai.New call creates a model that satisfies the same model.LLM interface as Gemini and Claude. The adapter wraps OpenAI's Chat Completions API behind ADK's unified interface, so the workflow engine treats all three providers identically.
- Find where productionAgent is created. Add the synthesis agent directly after the productionAgent error check:
synthesisAgent, err := llmagent.New(llmagent.Config{
Name: synthesisAgentName,
Model: openaiModel,
Description: "Synthesizes patent and production analyses into a speculation report.",
Instruction: synthesisInstruction,
})
if err != nil {
return nil, fmt.Errorf("creating synthesis agent: %w", err)
}
What does this code do?
This creates the third LLM agent in the pipeline. It uses the synthesisAgentName constant ("SpeculationSynthesizer") and the synthesisInstruction you added earlier. The OpenAI model powers this agent's reasoning while ADK handles the orchestration plumbing.
- Find the SubAgents line in the workflowagent.New call:
SubAgents: []agent.Agent{patentAgent, productionAgent},
- Update it to include synthesisAgent:
SubAgents: []agent.Agent{patentAgent, productionAgent, synthesisAgent},
- Save main.go and verify the build still passes:
go build ./...
No output means the build succeeded. The synthesis agent is registered but not yet wired into the graph edges.
Build failing after adding the OpenAI import?
- Run go mod tidy to ensure the dependency is resolved. The import path must be exactly github.com/ieshan/adk-go-pkg/model/openai.
- Check that OPENAI_API_KEY is exported in your terminal session. The model creation reads it at runtime, but missing imports cause compile-time failures.
help me fix a build error after adding the OpenAI adapter
Complete the graph wiring and register sub-agents
Right now the graph fans out to both research paths but their outputs disappear. You need to create a JoinNode that acts as a barrier, a format node that reshapes the joined map, and wire the complete flow from gather through synthesis.
- In buildPipeline, find where productionAgentNode is created. Add the following three new nodes directly after it:
// Create the synthesis agent node
synthesisAgentNode, err := workflow.NewAgentNode(synthesisAgent, workflow.NodeConfig{})
if err != nil {
return nil, fmt.Errorf("creating synthesis agent node: %w", err)
}
// JoinNode barrier waits for both researchers to finish
gatherNode := workflow.NewJoinNode("gather_research")
// Format function reshapes joined map into synthesis prompt
formatNode := workflow.NewFunctionNode("format_synthesis", formatSynthesisPrompt, workflow.NodeConfig{})
What does this code do?
The workflow.NewJoinNode("gather_research") creates a barrier that blocks until every node wired into it has completed. It collects their outputs into a map[string]any keyed by predecessor node name.
The formatNode wraps your formatSynthesisPrompt function. It receives the joined map and outputs the formatted string that the synthesis agent will process.
Now replace the existing Edges field in the workflowagent.New call with the complete fan-out/fan-in wiring.
- Find the current Edges field in the workflowagent.New config:
Edges: workflow.NewEdgeBuilder().
AddFanOut(workflow.Start, []workflow.Node{
workflow.Chain(patentDataNode, patentAgentNode),
workflow.Chain(productionDataNode, productionAgentNode),
}).Build(),
- Replace it with the complete fan-out/fan-in graph that routes through the JoinNode barrier and into synthesis:
Edges: workflow.NewEdgeBuilder().
AddFanOut(workflow.Start, []workflow.Node{
workflow.Chain(patentDataNode, patentAgentNode),
workflow.Chain(productionDataNode, productionAgentNode),
}).
AddFanIn(gatherNode, []workflow.Node{patentAgentNode, productionAgentNode}).
Add(gatherNode, formatNode).
Add(formatNode, synthesisAgentNode).
Build(),
What does this wiring do?
The graph now has three stages. First, AddFanOut launches both research paths in parallel from workflow.Start. Second, AddFanIn tells gatherNode to wait for both patentAgentNode and productionAgentNode before firing.
Third, the sequential Add calls chain gatherNode to formatNode to synthesisAgentNode. The result is a complete fan-out/fan-in diamond that fans out two researchers, waits for both, formats their combined output, and passes it to the synthesizer.
- Save main.go.
Before you run the full pipeline, what do you expect to see when you type "WEBB" as input? Think about what the three agents will produce together.
- Run the complete pipeline:
go run . console
- When prompted, type WEBB and press Enter.
You should see a complete Oil Speculation Report for Webb County. It includes an Executive Summary, a Ranked Opportunities table with patents scored 1-100, Risk Factors, and Recommended Next Steps. The report attributes findings to both the patent analysis and the production correlation.
Seeing a timeout or empty output?
- Confirm all three API keys are exported: GOOGLE_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY. A missing key causes a runtime error.
- If one agent returns an error, check that the model names are correct: gemini-2.5-flash, claude-sonnet-4-20250514, gpt-4o.
- The pipeline makes three LLM calls sequentially after the join, so allow 15-30 seconds for the full report to appear.
help me debug my fan-in pipeline that is not producing output
✔️ Awesome, I've got everything!
Great. Double check you've saved main.go and your pipeline produces a full report.
ⓧ I'd like to double check the full code
Your main.go is now large. Scroll through and confirm these sections are present in order:
- Imports include both adkanthropic and openai adapters.
- Three constants: patentInstruction, productionInstruction, synthesisInstruction.
- Helper functions: loadPatentData, loadProductionData, formatPatentPrompt, formatProductionPrompt, formatSynthesisPrompt.
- In buildPipeline: three models (geminiModel, claudeModel, openaiModel), three agents, all data/agent/join/format nodes.
- Edges with AddFanOut, AddFanIn, and two Add calls chaining gather to format to synthesis.
- SubAgents lists all three agents: patentAgent, productionAgent, synthesisAgent.
Your pipeline now orchestrates three LLM providers through a complete fan-out/fan-in pattern. Next up, you'll replace the static graph with a DynamicNode that decides at runtime which agents to invoke based on available data.
Upgrade to Dynamic Orchestration with DynamicNode
Your static fan-out graph works perfectly for Webb County because both patent and production data exist. But it always runs both research agents regardless of input.
ADK Go 2.0's headline feature solves this: DynamicNode replaces declarative graph wiring with imperative Go code that decides at runtime which agents to invoke based on what data actually exists.
In this step, get ready to:
- Replace the static fan-out graph with a DynamicNode orchestrator.
- Wire the DynamicNode as the sole graph entry point.
- Test both code paths with different county inputs.
Replace static fan-out with NewDynamicNode
A DynamicNode receives the user's input and uses plain Go logic to decide which child nodes to invoke via workflow.RunNode. This replaces the static EdgeBuilder fan-out entirely.
Why DynamicNode instead of static fan-out?
The static graph fires both research agents every time, even when a county has no production data. The production agent would receive an empty dataset and waste an API call returning nothing useful.
DynamicNode inspects the data first and only invokes agents whose input is meaningful. You write the routing logic in Go, not in a graph DSL.
- In main.go, add two new imports to your import block. Add these lines after the existing "google.golang.org/adk/v2/model/gemini" import:
"google.golang.org/adk/v2/model"
"google.golang.org/adk/v2/session"
- Inside the buildPipeline function, find the EdgeBuilder fan-out wiring code (the lines that use AddFanOut, AddFanIn, and eb.Add to wire the static graph).
- Delete all EdgeBuilder wiring lines. You will replace them with the DynamicNode below.
- Paste this code after the formatNode declaration. This is the first half of the orchestrator:
orchestrator := workflow.NewDynamicNode[string, string]("orchestrator",
func(nc agent.Context, county string, emit func(*session.Event) error) (string, error) {
county = strings.TrimSpace(strings.ToUpper(county))
patents, _ := loadPatentData(county)
wells, _ := loadProductionData(county)
hasPatents := len(patents) > 0
hasWells := len(wells) > 0
if !hasPatents {
return fmt.Sprintf("No land patent data available for %s County. Cannot perform analysis.", county), nil
}
patentPrompt, _ := formatPatentPrompt(nc, county)
patentResult, err := workflow.RunNode[string](nc, patentAgentNode, patentPrompt)
if err != nil {
return "", fmt.Errorf("patent agent failed: %w", err)
}
What does this code do?
- workflow.NewDynamicNode[string, string] creates a node that takes a string input (the county name) and returns a string output (the final report).
- The function body is plain Go. It loads data first, checks what exists, and returns early if no patents are found.
- workflow.RunNode[string] imperatively invokes a child node and waits for its result. This replaces the declarative edge wiring.
- Directly below the code you just pasted (still inside the same function literal), paste the conditional routing and synthesis logic:
var synthesisInput string
if hasWells {
productionPrompt, _ := formatProductionPrompt(nc, county)
productionResult, err := workflow.RunNode[string](nc, productionAgentNode, productionPrompt)
if err != nil {
return "", fmt.Errorf("production agent failed: %w", err)
}
synthesisInput = fmt.Sprintf("Combine these research findings into a speculation report for %s County:\n\n## Land Patent Analysis\n%s\n\n## Production Correlation\n%s", county, patentResult, productionResult)
} else {
synthesisInput = fmt.Sprintf("Produce a speculation report for %s County based on patent analysis only (no production data available):\n\n## Land Patent Analysis\n%s\n\n## Production Correlation\n(No well production data available for this county. Note limited data in your risk assessment.)", county, patentResult)
}
finalReport, err := workflow.RunNode[string](nc, synthesisAgentNode, synthesisInput)
if err != nil {
return "", fmt.Errorf("synthesis agent failed: %w", err)
}
return finalReport, nil
},
workflow.NodeConfig{},
)
What does the conditional routing do?
- If hasWells is true, the orchestrator runs the production correlator agent and builds a combined synthesis prompt from both results.
- If no production data exists, it skips the production agent entirely and tells the synthesizer to note limited data in its risk assessment.
- Either way, the synthesizer agent gets invoked last with whatever findings are available. The Go if/else replaces what would have been a complex conditional edge in a static graph.
Wire the DynamicNode as the sole graph entry point
The orchestrator now handles all routing internally. The workflowagent only needs a single edge from Start to the orchestrator.
- Find the workflowagent.New call in buildPipeline. Replace its Edges field so it uses workflow.Chain with the orchestrator:
wa, err := workflowagent.New(workflowagent.Config{
Name: "oil_speculation_engine",
Description: "Multi-agent pipeline for Texas land patent oil speculation analysis.",
Edges: workflow.Chain(workflow.Start, orchestrator),
SubAgents: []agent.Agent{patentAgent, productionAgent, synthesisAgent},
})
if err != nil {
return nil, fmt.Errorf("creating workflow agent: %w", err)
}
- The patentDataNode, productionDataNode, gatherNode, and formatNode are no longer wired into the graph. Add blank assignments after the workflowagent block to suppress unused-variable errors:
_ = patentDataNode
_ = productionDataNode
_ = gatherNode
_ = formatNode
return wa, nil
Why keep unused nodes in the code?
The DynamicNode's orchestrator function calls workflow.RunNode directly on the AgentNodes. The FunctionNodes and JoinNode were part of the static graph approach. Blanking them with _ = keeps the code compiling while showing the migration path.
- Also confirm your unused import for "google.golang.org/adk/v2/model" is present. Go requires it for the model.LLM interface type used by the adapters.
- Save main.go.
- Verify the refactored code compiles by running:
go build ./...
You should see no output. A clean build means the DynamicNode is syntactically wired and all imports resolve.
Seeing a compile error?
- If you see undefined: session, confirm you added the "google.golang.org/adk/v2/session" import.
- If you see declared and not used for the old nodes, make sure all four _ = blank assignments are present.
- If the orchestrator function body has a syntax error, check that the closing }, and workflow.NodeConfig{}, and final ) are all present at the end of the DynamicNode declaration.
Help me debug my DynamicNode compile error
Test both code paths
The whole point of the DynamicNode is conditional routing. Your sample data has Webb County with both patents and wells, and Kinney County with patents only.
- Run the engine with the full-data county by running:
go run . console
- When prompted, type WEBB and press Enter.
Before the output appears, predict: how many LLM agents do you think will be invoked for Webb County?
You should see a full speculation report that references both the land patent analysis and the production correlation findings. All three agents fired: Gemini for patents, Claude for production, and OpenAI for synthesis.
- Exit the console with Ctrl+C.
- Run the engine again:
go run . console
- This time, type KINNEY and press Enter.
Now predict: Kinney County has patent records but no well production data. Will the production correlator agent fire?
You should see a report that only references patent analysis. The synthesizer notes limited data in its risk factors because the DynamicNode skipped the production agent entirely. Only two LLM calls were made instead of three.
What just happened?
The same orchestrator handled two completely different execution paths using plain Go if/else logic. No graph edges were reconfigured. No conditional node types were declared.
This is the power of imperative orchestration: your routing logic lives in Go code you can test, debug, and extend with any condition you can express in the language.
Seeing the same report for both counties?
- Make sure the orchestrator normalizes input with strings.ToUpper(county). The sample data uses uppercase county names.
- Check that data/wells.json only contains records with "county": "WEBB". If you accidentally added Kinney well records, the conditional path won't trigger.
Why is my DynamicNode running the same path for both counties?
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go with the DynamicNode orchestrator and the updated workflowagent wiring.
ⓧ I'd like to double check the full code
Your main.go is long. Scroll through and confirm you have these sections in order:
- Import block with "google.golang.org/adk/v2/model" and "google.golang.org/adk/v2/session" alongside the existing imports.
- Constants and instruction strings (unchanged).
- Data loading and formatting functions (unchanged).
- In buildPipeline: model creation, agent creation, node creation (patentDataNode, productionDataNode, patentAgentNode, productionAgentNode, synthesisAgentNode, gatherNode, formatNode).
- The orchestrator DynamicNode with the conditional routing logic.
- workflowagent.New using workflow.Chain(workflow.Start, orchestrator) for Edges.
- Four _ = blank assignments for the unused nodes.
- The main function (unchanged).
You have replaced a rigid static graph with imperative runtime orchestration. The engine now adapts to whatever data exists for a given county. Next up: the Secret Mission adds a human approval gate so you review the research before synthesis runs.
Secret mission
Add Human-in-the-Loop Approval Gate
Your pipeline runs three AI agents and produces a speculation report with zero human oversight. In production, that is a liability. Add an approval gate using ADK Go 2.0's built-in RequestInput so the orchestrator pauses after research completes and waits for your explicit sign-off before the synthesizer fires.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs. API charges only occur when you actively run the pipeline.
Resources you used:
- The oil-speculation-engine project folder on your Desktop.
- Go module cache entries for ADK Go v2, adk-anthropic-go, and adk-go-pkg.
- Environment variables (GOOGLE_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY) set in your terminal session.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the pipeline with new agents or data sources.
- Your project folder stays on your Desktop, ready to run again.
- Your environment variables remain active for the current terminal session. If you added them to your shell profile (.zshrc or .bashrc), they persist across sessions.
- Go module cache entries remain available for faster builds in future Go projects.
Pause - I'll come back to this later
Since there are no running processes or cloud resources, pausing is effectively the same as keeping. The only ongoing concern is your API keys.
- If you exported your API keys in the terminal, they disappear when you close that terminal window.
- If you added API keys to your shell profile, they stay available but do not incur costs unless you run the pipeline.
- No services are running in the background. You can close your terminal and editor.
Delete - I don't want to use this again
Remove all project files and clean up cached dependencies.
- Delete the project folder by running this command:
cd ~/Desktop
rm -rf oil-speculation-engine
- Confirm the folder is gone by running:
ls ~/Desktop | grep oil
You should see no output.
- If you want to remove the cached Go module downloads for this project (note: this removes all cached modules for all Go projects), run:
go clean -modcache
- If you added API keys to your shell profile, open ~/.zshrc (macOS) or ~/.bashrc (Linux/Windows WSL) and remove the lines exporting GOOGLE_API_KEY, ANTHROPIC_API_KEY, and OPENAI_API_KEY.
- If you only exported the keys in your terminal session, closing the terminal window clears them automatically.
Well Done!
Well Done!
Nice work! You just built a production-patterned multi-agent oil speculation engine that dynamically orchestrates three specialist AI agents across three different LLM providers using ADK Go 2.0.
You've learned how to:
- Build a dynamic graph orchestration pipeline using ADK Go 2.0's DynamicNode to make runtime routing decisions based on available data.
- Compose three different LLM providers (Gemini, Claude, OpenAI) through the unified model.LLM interface for multi-provider agent workflows.
- Implement fan-out/fan-in patterns with JoinNode barriers to coordinate parallel agent execution and collect results before synthesis.
- Secret Mission: Added a human-in-the-loop approval gate using RequestInput to pause the pipeline for human review before generating the final speculation report.
Ready to quiz yourself?