Skip to main content

Google ADK integration

View Markdown

Temporal's integration with Google ADK (adk-go) gives your agents Durable Execution: the agent's orchestration loop runs inside a Temporal Workflow, each LLM call becomes a durable Temporal Activity, and any tool that does I/O runs as an Activity too — so every step is retried, timed out, recorded in Workflow history, and replayable after crashes or restarts.

You keep building agents the native ADK way — llmagent.New(...) with a model.LLM, tool.Tools / tool.Toolsets and SubAgents, wrapped in runner.New(...) and driven by r.Run(...). You change two things:

  1. Use googleadk.NewModel("<model-name>") as your agent's Model. It is a model.LLM whose calls dispatch to the InvokeModel Activity; the real model is reconstructed worker-side, never in the Workflow.
  2. Pass googleadk.NewContext(workflowCtx) to r.Run, which installs Temporal-deterministic time, UUID, and task-fan-out providers so the agent loop replays deterministically.

Tools run in-workflow by default (the idiomatic Temporal model: the Workflow is deterministic, and anything touching the network, clock, or disk goes through an Activity). Opt a tool into an Activity with googleadk.ActivityAsTool, or use googleadk.NewMCPToolset for MCP.

info

The googleadk contrib module is new. It depends on determinism seams (platform.WithTimeProvider, WithUUIDProvider, WithTaskRunner) that merged into google.golang.org/adk/v2 after its latest tagged release, so go.mod pins adk/v2 to a main-branch pseudo-version until a release ships that includes them.

Code snippets in this guide are taken from the Google ADK plugin samples. Refer to the samples for the complete, runnable code.

Prerequisites

  • This guide assumes you are already familiar with Google ADK. If you aren't, refer to the Google ADK documentation for more details.
  • If you are new to Temporal, read Understanding Temporal or take the Temporal 101 course.
  • Set up your local development environment by following the Set up your local development environment guide. Leave the Temporal development server running if you want to test your code locally.
  • Provide model credentials worker-side — for Gemini, set GEMINI_API_KEY (or GOOGLE_API_KEY) in the worker's environment. Credentials are captured in the worker's ModelFactory and never cross the Activity boundary into the Workflow.

Install the plugin

Install the googleadk contrib module:

go get go.temporal.io/sdk/contrib/googleadk@latest
import "go.temporal.io/sdk/contrib/googleadk"

Run an agent with Durable Execution

An agent has two halves: the worker registers the real model behind the InvokeModel Activity, and the workflow builds a vanilla ADK agent and drives it.

Configure the Worker

Build the worker-side registry with googleadk.NewActivities and register it. The real Gemini model lives here, behind the Activity boundary; the API key is read worker-side. Disable the model SDK's own retries so Temporal's RetryPolicy is the single source of truth.

googleadk/worker/main.go

w := worker.New(c, adk.TaskQueue, worker.Options{})

w.RegisterWorkflow(adk.AgentWorkflow)
// Register GetWeather under the tool name the ActivityAsTool dispatches, so the
// agent's get_weather call resolves to this activity.
w.RegisterActivityWithOptions(adk.GetWeather, activity.RegisterOptions{Name: adk.WeatherToolName})

// Register the integration's model Activity. The real Gemini model lives here,
// behind the Activity boundary; the API key is read worker-side from the env
// and never crosses into the workflow. Disable the model SDK's own retries so
// Temporal's RetryPolicy is the single source of truth.
acts, err := googleadk.NewActivities(googleadk.Config{
Models: map[string]googleadk.ModelFactory{
adk.ModelName: func(ctx context.Context, name string) (model.LLM, error) {
// nil config reads GEMINI_API_KEY / GOOGLE_API_KEY from the env.
return gemini.NewModel(ctx, name, nil)
},
},
})
if err != nil {
log.Fatalln("Unable to build googleadk activities", err)
}
acts.Register(w)

if err := w.Run(worker.InterruptCh()); err != nil {
log.Fatalln("Unable to start worker", err)
}

Config.Models is optional for providers ADK's registry already knows (for example gemini-*): when a model name is absent, InvokeModel falls back to model.NewLLM. Supply a factory to inject credentials, disable the model SDK's own retries, or override the default.

Define the Workflow

Build the agent the ordinary ADK way, using googleadk.NewModel for the model and passing googleadk.NewContext(ctx) to r.Run. The get_weather tool is an ordinary Temporal Activity exposed to the agent with googleadk.ActivityAsTool.

googleadk/workflow.go

func AgentWorkflow(ctx workflow.Context, question string) (string, error) {
weatherTool, err := googleadk.ActivityAsTool(GetWeather, googleadk.ActivityToolOptions{
Name: WeatherToolName,
Description: "Get the current weather for a city.",
})
if err != nil {
return "", err
}

// Build the agent the ordinary ADK way. NewModel is a model.LLM that carries
// only the model name in-workflow; the real Gemini client lives worker-side.
root, err := llmagent.New(llmagent.Config{
Name: "assistant",
Description: "a helpful weather assistant",
Model: googleadk.NewModel(ModelName),
Instruction: "Answer the user's question. Use the get_weather tool when asked about the weather.",
Tools: []tool.Tool{weatherTool},
})
if err != nil {
return "", err
}

r, err := runner.New(runner.Config{
AppName: "weather",
Agent: root,
SessionService: session.InMemoryService(),
AutoCreateSession: true,
})
if err != nil {
return "", err
}

// NewContext bridges the workflow.Context into the context ADK reads its
// determinism/executor seams from. Pass it straight to Run.
adkCtx := googleadk.NewContext(ctx)
msg := genai.NewContentFromText(question, genai.RoleUser)

var answer string
for ev, err := range r.Run(adkCtx, "user-1", "session-1", msg, agent.RunConfig{}) {
if err != nil {
return "", err
}
if ev != nil && ev.Content != nil {
for _, p := range ev.Content.Parts {
if p != nil && p.Text != "" {
answer = p.Text
}
}
}
}
return answer, nil
}

The tool itself is an ordinary Temporal Activity — register it on the worker as usual, and expose it to the agent with ActivityAsTool (its parameter schema is inferred from the argument type):

googleadk/workflow.go

func GetWeather(ctx context.Context, in GetWeatherInput) (GetWeatherOutput, error) {
return GetWeatherOutput{City: in.City, Conditions: "sunny, 72°F"}, nil
}

Start the Workflow

Start the Workflow like any other and read its result:

googleadk/starter/main.go

question := "What's the weather in San Francisco?"
we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, adk.AgentWorkflow, question)
if err != nil {
log.Fatalln("Unable to execute workflow", err)
}

log.Println("Started workflow", "WorkflowID", we.GetID(), "RunID", we.GetRunID())

// Synchronously wait for the workflow completion.
var answer string
if err := we.Get(context.Background(), &answer); err != nil {
log.Fatalln("Unable to get workflow result", err)
}
log.Println("Agent answer:", answer)

Tools

  • Function tools run in-workflow by default. Ordinary functiontool.New(...) tools run on Temporal's deterministic dispatcher inside the Workflow — no Activity overhead — and their session-state mutations propagate normally. Their code must be deterministic and replay-safe: no direct network, clock, randomness, or goroutines.
  • Opt a tool into an Activity when it does I/O. googleadk.ActivityAsTool(myActivity, ...) exposes an existing func(context.Context, TArgs) (TResults, error) Temporal Activity to the agent as a tool (shown in the Hello World Workflow above); its call dispatches the Activity, so it is retried, timed out, and visible in the UI.
  • MCP, statelessly. googleadk.NewMCPToolset(...) is a workflow-side proxy that lists remote tools via the ListMcpTools Activity and executes calls via CallMcpTool. The live, stateful mcptoolset.New(...) runs worker-side (registered in Config.MCPToolsets), never in the Workflow.

Multi-agent systems

Build a coordinator agent with specialist SubAgents; ADK wires the parent/child relationship and exposes the built-in transfer_to_agent tool automatically. The entire tree — including the transfer hop — runs in the Workflow; only the model calls and any Activity-backed tools leave it.

googleadk/multiagent/workflow.go

func MultiAgentWorkflow(ctx workflow.Context, question string) (string, error) {
weatherTool, err := googleadk.ActivityAsTool(GetWeather, googleadk.ActivityToolOptions{
Name: WeatherToolName,
Description: "Get the current weather for a city.",
})
if err != nil {
return "", err
}

// The weather specialist owns the get_weather tool.
weather, err := llmagent.New(llmagent.Config{
Name: "weather",
Description: "answers questions about the current weather in a city",
Model: googleadk.NewModel(WeatherModelName),
Instruction: "You are a weather specialist. Use the get_weather tool to answer weather questions.",
Tools: []tool.Tool{weatherTool},
})
if err != nil {
return "", err
}

// The jokes specialist just tells jokes.
jokes, err := llmagent.New(llmagent.Config{
Name: "jokes",
Description: "tells a light-hearted joke",
Model: googleadk.NewModel(JokesModelName),
Instruction: "You are a comedian. Respond with a short, friendly joke.",
})
if err != nil {
return "", err
}

// The coordinator delegates to whichever specialist fits the question. ADK
// wires the parent/child relationship from SubAgents and exposes the built-in
// transfer_to_agent tool automatically.
coordinator, err := llmagent.New(llmagent.Config{
Name: "coordinator",
Description: "routes the user's request to the right specialist",
Model: googleadk.NewModel(CoordinatorModelName),
Instruction: "You are a router. Delegate weather questions to the weather agent " +
"and requests for a joke to the jokes agent. Do not answer directly.",
SubAgents: []agent.Agent{weather, jokes},
})
if err != nil {
return "", err
}

r, err := runner.New(runner.Config{
AppName: "multiagent",
Agent: coordinator,
SessionService: session.InMemoryService(),
AutoCreateSession: true,
})
if err != nil {
return "", err
}

adkCtx := googleadk.NewContext(ctx)
msg := genai.NewContentFromText(question, genai.RoleUser)

var answer string
for ev, err := range r.Run(adkCtx, "user-1", "session-1", msg, agent.RunConfig{}) {
if err != nil {
return "", err
}
if ev == nil || ev.Content == nil {
continue
}
// Keep the last non-empty text produced by any agent in the tree; after a
// transfer_to_agent hop this is the specialist's answer.
for _, p := range ev.Content.Parts {
if p != nil && p.Text != "" {
answer = p.Text
}
}
}
return answer, nil
}

Human-in-the-loop tool confirmation

A sensitive tool calls ADK's ctx.RequestConfirmation(hint, payload), which ends the turn with an adk_request_confirmation function call. The Workflow detects pending confirmations with googleadk.PendingConfirmations, durably waits for the human's decision (delivered as a Temporal signal), and resumes the agent with googleadk.ConfirmationResponse. Because the wait is durable, the Workflow can sit idle for days and survive worker restarts — when the approval signal arrives, the agent resumes exactly where it paused.

googleadk/humanintheloop/workflow.go

func ApprovalWorkflow(ctx workflow.Context, request string) (Result, error) {
delTool, err := functiontool.New[DeleteArgs, map[string]any](
functiontool.Config{
Name: DeleteToolName,
Description: "Delete a named resource. Requires human confirmation before it runs.",
},
deleteResource,
)
if err != nil {
return Result{}, err
}

root, err := llmagent.New(llmagent.Config{
Name: "assistant",
Description: "an assistant that can delete resources with human approval",
Model: googleadk.NewModel(ModelName),
Instruction: "Use the delete_resource tool when the user asks to delete something.",
Tools: []tool.Tool{delTool},
})
if err != nil {
return Result{}, err
}

r, err := runner.New(runner.Config{
AppName: "hitl",
Agent: root,
SessionService: session.InMemoryService(),
AutoCreateSession: true,
})
if err != nil {
return Result{}, err
}

adkCtx := googleadk.NewContext(ctx)
msg := genai.NewContentFromText(request, genai.RoleUser)

var res Result
// Drive the run in passes: each Run call is one pass over the same session. A
// pass either completes (no pending confirmation) or pauses awaiting a human.
for {
var events []*session.Event
for ev, err := range r.Run(adkCtx, "user-1", "session-1", msg, agent.RunConfig{}) {
if err != nil {
return Result{}, err
}
if ev == nil {
continue
}
events = append(events, ev)
if ev.Content != nil {
for _, p := range ev.Content.Parts {
if p != nil && p.Text != "" {
res.Answer = p.Text
}
}
}
}

pending := googleadk.PendingConfirmations(events)
if len(pending) == 0 {
// The agent finished without (further) confirmations needed.
return res, nil
}

// The agent paused. Durably wait for the human's decision to arrive as a
// Temporal signal. This is the whole point: the workflow can sit here for
// as long as it takes — across worker restarts — without losing state.
//
// This handles one pending confirmation per pass — the recommended
// pattern (see the googleadk.ConfirmationResponse docs): resuming
// several decisions at once can re-dispatch the approved tool calls in
// an order that is not replay-stable. Any other pending confirmations
// simply surface again on the next pass.
var decision googleadk.ConfirmationDecision
workflow.GetSignalChannel(ctx, googleadk.ConfirmationSignalName).Receive(ctx, &decision)
res.Approved = decision.Confirmed

// Match the decision to the pending confirmation and resume the run with
// it as the next message. ADK re-dispatches (or blocks) the original tool
// call based on Confirmed.
if decision.FunctionCallID == "" {
decision.FunctionCallID = pending[0].FunctionCallID
}
msg = googleadk.ConfirmationResponse(decision)
}
}

Continue-as-new for long conversations

A conversation's history lives in the ADK session. To keep a Workflow's history bounded, snapshot the session with googleadk.ExportSession and continue-as-new; rebuild it on the next run with googleadk.ImportSession. SessionSnapshot is JSON-serializable (session-scoped state plus the full event history), so every value in session state and every tool result must be JSON-encodable.

googleadk/chat/workflow.go

func ChatWorkflow(ctx workflow.Context, in ChatInput) error {
// A fresh in-memory session service, kept in a local so we can Export it later.
svc := session.InMemoryService()

adkCtx := googleadk.NewContext(ctx)

// Resume a prior conversation if this run was continued-as-new.
if in.Snapshot != nil {
if _, err := googleadk.ImportSession(adkCtx, svc, in.Snapshot); err != nil {
return err
}
}

root, err := llmagent.New(llmagent.Config{
Name: "assistant",
Description: "a friendly conversational assistant",
Model: googleadk.NewModel(ModelName),
Instruction: "You are a helpful assistant. Answer the user, using the conversation history for context.",
})
if err != nil {
return err
}

r, err := runner.New(runner.Config{
AppName: AppName,
Agent: root,
SessionService: svc,
AutoCreateSession: true,
})
if err != nil {
return err
}

turns := 0
// One agent turn runs at a time: serialize concurrent Updates so they can't
// interleave on the shared ADK session.
busy := false

err = workflow.SetUpdateHandlerWithOptions(
ctx,
SendMessageUpdateName,
func(ctx workflow.Context, text string) (string, error) {
if err := workflow.Await(ctx, func() bool { return !busy }); err != nil {
return "", err
}
busy = true
defer func() { busy = false }()

// Build the ADK context from this Update handler's own workflow.Context so
// the model Activity is scheduled on the handler's coroutine.
turnCtx := googleadk.NewContext(ctx)
var answer string
msg := genai.NewContentFromText(text, genai.RoleUser)
for ev, err := range r.Run(turnCtx, UserID, SessionID, msg, agent.RunConfig{}) {
if err != nil {
return "", err
}
if ev == nil || ev.Content == nil {
continue
}
for _, p := range ev.Content.Parts {
if p != nil && p.Text != "" {
answer = p.Text
}
}
}
turns++
return answer, nil
},
workflow.UpdateHandlerOptions{
Validator: func(ctx workflow.Context, text string) error {
if text == "" {
return fmt.Errorf("message must not be empty")
}
return nil
},
},
)
if err != nil {
return err
}

// Serve messages until Temporal suggests continue-as-new (history getting large)
// or the demo turn cap is reached.
if err := workflow.Await(ctx, func() bool {
return workflow.GetInfo(ctx).GetContinueAsNewSuggested() || (in.MaxTurns > 0 && turns >= in.MaxTurns)
}); err != nil {
return err
}

// Let any in-flight Update finish so its turn is captured in the snapshot.
if err := workflow.Await(ctx, func() bool { return workflow.AllHandlersFinished(ctx) }); err != nil {
return err
}

snap, err := googleadk.ExportSession(adkCtx, svc, AppName, UserID, SessionID)
if err != nil {
return err
}
return workflow.NewContinueAsNewError(ctx, ChatWorkflow, ChatInput{
Snapshot: snap,
MaxTurns: in.MaxTurns,
})
}

Streaming

googleadk.NewModel(name, googleadk.WithStreaming(topic, 0)) drives the model in streaming mode: the InvokeModel Activity calls the model with stream=true, heartbeats, and publishes each chunk to a per-run workflowstreams topic for external (UI) consumers, then returns the aggregated final response into the Workflow so replay stays deterministic.

Call googleadk.StreamServer(ctx) once near the top of the Workflow that drives r.Run, and set agent.RunConfig{StreamingMode: agent.StreamingModeSSE}:

func StreamingAgentWorkflow(ctx workflow.Context, q string) (string, error) {
if err := googleadk.StreamServer(ctx); err != nil { // required when streaming
return "", err
}
topic := "run-" + workflow.GetInfo(ctx).WorkflowExecution.ID
root, _ := llmagent.New(llmagent.Config{
Model: googleadk.NewModel("gemini-2.0-flash", googleadk.WithStreaming(topic, 0)),
// ...
})
// ... build the runner, set agent.RunConfig{StreamingMode: agent.StreamingModeSSE}, and drive r.Run
}

External consumers read chunks with workflowstreams.NewClient(c, workflowID, ...).Subscribe(...). The bidirectional RunLive path (hard-coded goroutines/channels) is not supported.

Error handling

Model, tool, and MCP failures surface as Temporal ApplicationErrors tagged googleadk.ModelError, .ToolError, and .McpError. Classify them with googleadk.IsNonRetryable(err) rather than string-matching. For model calls, the upstream HTTP status drives retryability (408/409/429/5xx are retryable; other 4xx are not).

tip

Disable your model client's own retries in the ModelFactory. InvokeModel already runs under Temporal's RetryPolicy; leaving the model SDK's retries on retries a transient failure twice over. Let Temporal own retries.

Composing with other plugins

The Temporal-side Activities use the default JSON data converter and ship no client/worker interceptor, so this integration composes with Temporal interceptor- or converter-based plugins (for example sdk-go/contrib/opentelemetry) without conflict. ADK emits its own OpenTelemetry spans; register your tracing interceptor on the worker as usual.

Testing without a live LLM

The plugin ships test helpers so you can unit-test agent Workflows with no network: FakeModel (with TextResponse / FunctionCallResponse builders) and FakeMCPServer. Register them through the same googleadk.Config your production worker uses.

Supported and not-yet-supported

  • Supported: single- and multi-agent (SubAgents) trees, in-workflow function tools, ActivityAsTool, stateless MCP, Gemini built-in tools (executed server-side inside InvokeModel), human-in-the-loop tool confirmation, continue-as-new session-state carry, the in-memory session service, and SSE streaming.
  • Not yet: RunLive (bidirectional streaming), sub-agent-as-child-workflow, live memory/artifact tools that require in-workflow network I/O, and database/Vertex session services. These raise or are documented rather than silently degrading.

Samples

The Google ADK plugin samples demonstrate a basic agent with a tool, a multi-agent system, durable human-in-the-loop tool approval, and a continue-as-new chat.