This tutorial walks you through building a durable AI agent that uses the Gemini API for reasoning and Temporal for durability. It uses Temporal's built-in Gemini SDK integration.
The agent can call tools, like looking up weather alerts or geolocating an IP address, and will loop until it has enough information to respond.
What makes this different from a typical agent demo is durability. Every LLM call and every tool invocation is persisted by Temporal. If the process crashes, the network drops, or an API times out, Temporal automatically retries and resumes from the last completed step. No conversation history is lost, and no tool calls are incorrectly repeated.
Architecture
The architecture consists of three parts:
- Workflow: A single
generate_contentcall. The Gemini SDK's automatic function calling (AFC) loop runs inside the Workflow, and Temporal makes every step of it durable. - Activities: Individual units of work that Temporal makes durable. The Gemini API calls become Activities automatically.
- Worker: The process that executes the Workflows and Activities, and the only place your API key lives.
In this example, you will place all three of these pieces in a single file
(durable_agent_worker.py). In a real-world implementation, you would separate
them to allow for various deployment and scalability advantages. You will supply
prompts to the agent with the Temporal CLI, so there is no client code to write.
Prerequisites
To complete this guide, you'll need:
- A Gemini API key. You can create one for free in Google AI Studio.
- Python version 3.10 or later.
- uv for dependency management.
- The Temporal CLI for running a local development server and starting Workflows.
Setup
Before you begin, ensure you have a Temporal development server running locally:
temporal server start-devNext, create a project and install the required dependencies:
uv init durable-gemini-agentcd durable-gemini-agentuv add "temporalio[google-genai]" httpx python-dotenv
uv creates and manages the virtual environment for you, so every Python command
later in this tutorial runs through uv run.
Create a .env file in your project directory with your Gemini API key. You
can get an API key from
Google AI Studio.
echo "GOOGLE_API_KEY=your-api-key-here" > .envImplementation
The rest of this tutorial walks through durable_agent_worker.py from top to
bottom, building up the agent piece by piece. Create the file and follow along.
Imports and sandbox setup
Start with the imports that must be defined up-front. The
workflow.unsafe.imports_passed_through() block tells Temporal's Workflow
sandbox to let httpx pass through without restriction. Importing httpx
executes class _CookieCompatRequest(urllib.request.Request), and the sandbox
blocks subclassing that stdlib class.
Your tools use httpx, and activity_as_tool() needs the Workflow to import
those tool functions so Gemini can derive their schemas from the signatures. So
httpx reaches the sandbox no matter how you split the files—moving the tools
into their own module doesn't avoid it.
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
import httpx
You don't need to list google.genai here. The Temporal plugin you configure
later adds it—along with pydantic_core and annotated_types—to the sandbox
passthrough set for you.
System instructions
Next, define the agent's personality. The system instructions tell the model how to behave. This agent is instructed to respond in haikus when no tools are needed.
SYSTEM_INSTRUCTIONS = """
You are a helpful agent that can use tools to help the user.
You will be given an input from the user and a list of tools to use.
You may or may not need to use the tools to satisfy the user ask.
If no tools are needed, respond in haikus.
"""
Tool definitions
Now define the tools the agent can use. Each tool is an ordinary Temporal
Activity: an async function decorated with @activity.defn, with type-annotated
parameters and a descriptive docstring. Gemini builds the function declaration
from that signature and docstring, so document each parameter in the Args
section.
import json
from temporalio import activity
NWS_API_BASE = "https://api.weather.gov"
USER_AGENT = "weather-app/1.0"
@activity.defn
async def get_weather_alerts(state: str) -> str:
"""Get weather alerts for a US state.
Args:
state: Two-letter US state code (e.g. CA, NY)
"""
headers = {"User-Agent": USER_AGENT, "Accept": "application/geo+json"}
url = f"{NWS_API_BASE}/alerts/active/area/{state}"
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers, timeout=5.0)
response.raise_for_status()
return json.dumps(response.json())
Next, define tools for IP address geolocation:
@activity.defn
async def get_ip_address() -> str:
"""Get the public IP address of the current machine."""
async with httpx.AsyncClient() as client:
response = await client.get("https://icanhazip.com")
response.raise_for_status()
return response.text.strip()
@activity.defn
async def get_location_info(ipaddress: str) -> str:
"""Get the location information for an IP address including city, state, and country.
Args:
ipaddress: An IP address to look up
"""
async with httpx.AsyncClient() as client:
response = await client.get(f"http://ip-api.com/json/{ipaddress}")
response.raise_for_status()
result = response.json()
return f"{result['city']}, {result['regionName']}, {result['country']}"
That's the whole tool layer. There is no tool registry, no FunctionDeclaration
construction, and no dispatch table—the next section wraps these Activities with
activity_as_tool(), which passes each parameter through to the Activity
positionally. Tools with zero, one, or several parameters all work.
The agent Workflow
Now you have all the pieces to finish building the agent. The AgentWorkflow
class makes one generate_content call. TemporalAsyncClient is a drop-in
AsyncClient whose every API call runs as a Temporal Activity, and
activity_as_tool() turns each of your Activities into a Gemini tool.
When the model asks for a tool, the SDK's AFC loop—running inside the
Workflow—dispatches it through workflow.execute_activity, appends the result
to the conversation, and calls the model again. That loop is the agent, and it
is durable because each step is an Activity recorded in Temporal's event
history.
from datetime import timedelta
from google.genai import types
from temporalio.contrib.google_genai import TemporalAsyncClient, activity_as_tool
from temporalio.workflow import ActivityConfig
TOOL_CONFIG = ActivityConfig(start_to_close_timeout=timedelta(seconds=30))
@workflow.defn
class AgentWorkflow:
"""Agent workflow that uses Gemini for LLM calls and executes tools."""
@workflow.run
async def run(self, prompt: str) -> str:
client = TemporalAsyncClient()
response = await client.models.generate_content(
model="gemini-3.7-flash",
contents=prompt,
config=types.GenerateContentConfig(
system_instruction=SYSTEM_INSTRUCTIONS,
tools=[
activity_as_tool(get_weather_alerts, activity_config=TOOL_CONFIG),
activity_as_tool(get_ip_address, activity_config=TOOL_CONFIG),
activity_as_tool(get_location_info, activity_config=TOOL_CONFIG),
],
),
)
# Leave this in place. You will un-comment it during a durability
# test later on.
# await workflow.sleep(timedelta(seconds=10))
return response.text or ""
A few things to note:
- Construct
TemporalAsyncClientinside the Workflow. It carries no credentials; it only knows how to turn API calls into Activity invocations. activity_configmust setstart_to_close_timeoutorschedule_to_close_timeout. Temporal requires a timeout and there is no default for tool Activities.- The Gemini API Activities default to a 60-second
start_to_close_timeout. Override it withTemporalAsyncClient(activity_config=...)if your model calls need longer.
The agent is fully durable. If the worker crashes after several turns, Temporal picks up exactly where it left off without re-invoking already executed LLM calls or tool calls.
Retries
Temporal owns retries, so don't enable the Gemini SDK's own retry loop. Set
retry behavior with a retry_policy on the Activity config instead:
from temporalio.common import RetryPolicy
TOOL_CONFIG = ActivityConfig(
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=3),
)
API failures are also classified for you. Transient statuses (408, 429, 5xx) stay retryable so the Activity's retry policy applies; other statuses (such as a 400 for a malformed request) are non-retryable, so the Workflow fails fast instead of burning attempts on an error that won't resolve.
You can extend that classification. The integration surfaces each API failure as
an ApplicationError whose type is the Gemini exception class name—ClientError
for 4xx, ServerError for 5xx—so listing a name in
non_retryable_error_types moves it out of the transient set. For example, to
stop retrying Gemini-side outages and fail the Workflow on the first 5xx, apply
the policy to the Gemini API Activities through TemporalAsyncClient:
from temporalio.common import RetryPolicy
client = TemporalAsyncClient(
activity_config=ActivityConfig(
start_to_close_timeout=timedelta(seconds=60),
retry_policy=RetryPolicy(
maximum_attempts=5,
non_retryable_error_types=["ServerError"],
),
),
)
Worker startup
Finally, wire everything together. The Temporal worker connects to the Temporal service and acts as a scheduler for the Workflow and Activity tasks.
This is where the real genai.Client is created, with your API key.
GoogleGenAIPlugin takes that client and registers the Gemini API Activities,
installs the Pydantic data converter, and configures the Workflow sandbox.
import asyncio
import os
from dotenv import load_dotenv
from google import genai
from temporalio.client import Client
from temporalio.contrib.google_genai import GoogleGenAIPlugin
from temporalio.envconfig import ClientConfig
from temporalio.worker import Worker
async def main():
gemini = genai.Client(api_key=os.environ["GOOGLE_API_KEY"])
plugin = GoogleGenAIPlugin(gemini)
config = ClientConfig.load_client_connect_config()
config.setdefault("target_host", "localhost:7233")
client = await Client.connect(**config, plugins=[plugin])
worker = Worker(
client,
task_queue="gemini-agent",
workflows=[
AgentWorkflow,
],
activities=[
get_weather_alerts,
get_ip_address,
get_location_info,
],
)
await worker.run()
if __name__ == "__main__":
load_dotenv()
asyncio.run(main())
The plugin removes three pieces of boilerplate you would otherwise need here:
- No
data_converter=pydantic_data_converter—the plugin installs the Pydantic payload converter itself. - No
activity_executor=ThreadPoolExecutor—every Activity is async. - No Gemini Activities in the
activitieslist—the plugin registers them. You only register your own tools.
Run the agent
That's the entire agent. You don't need to write a client—the Temporal CLI can start the Workflow for you.
If you haven't already, start the Temporal development server:
temporal server start-devIn a new terminal window, start the agent worker:
uv run durable_agent_worker.pyIn a third terminal window, submit a query to your agent:
temporal workflow execute --type AgentWorkflow --task-queue gemini-agent \
--input '"are there any weather alerts for where I am?"'Note the task queue: it's the same one the worker polls. Starting the Workflow
dispatches a Workflow task carrying the user prompt to that queue, which is what
initiates the agent. execute blocks until the Workflow completes and prints the
result. If you'd rather not wait, use temporal workflow start with an explicit
--workflow-id, then collect the result later with
temporal workflow result -w your-workflow-id. Temporal generates the Workflow
ID for you when you omit --workflow-id.
--input takes JSON, so a bare string prompt needs its own quotes inside the
shell quotes. The CLI needs no Gemini API key, and no data converter
configuration either: the Workflow's argument and return value are both plain
strings, which the default JSON payload converter handles.
Open the Temporal UI at
http://localhost:8233/namespaces/default/workflows to watch the agentic loop
unfold. You'll see gemini_api_client_async_request Activities—one per model
turn—interleaved with one Activity per tool call, each labeled with a
tool_call summary. That interleaving is the AFC loop, made durable and
observable.
Try a few different prompts to see the agent reason and call tools. Each command
is the same as the one above with a new --input:
temporal workflow execute --type AgentWorkflow --task-queue gemini-agent \ --input '"are there any weather alerts for New York?"'temporal workflow execute --type AgentWorkflow --task-queue gemini-agent \ --input '"where am I?"'temporal workflow execute --type AgentWorkflow --task-queue gemini-agent \ --input