Build a Google ADK agent in an AI sandbox
This guide walks you through running a Google Agent Development Kit (ADK) agent on Windmill as a single script. The agent runs inside an AI sandbox, keeps its session state in a volume, and exposes a clean main() entry point you can call from a flow, a trigger, or the UI.
ADK is a code-first framework for building, evaluating, and orchestrating agents. It is optimized for Gemini but model-agnostic, so the same script works with other providers via LiteLLM. The same patterns shown here work with both the Python (google-adk) and the TypeScript (@google/adk) ports.
What you'll build
A weather assistant agent that:
- Defines a single ADK agent with one function tool.
- Runs inside an nsjail sandbox so the agent process is isolated from the worker.
- Persists session history to a SQLite database stored in a volume, so subsequent runs resume where the conversation left off.
- Takes a
promptand agoogleairesource as script inputs and returns the agent's final response.
Prerequisites
- A Windmill instance with workspace object storage configured (required for volumes).
- Workers with
nsjailavailable (included in standard Windmill Docker images). - A Google AI Studio API key — get one at aistudio.google.com/apikey.
- A
googleairesource holding the API key. Thegoogleairesource type ships on the Windmill Hub; create a resource of that type and paste your key intoapi_key.
Step 1: Create the script
Create a new script and paste the code for your language. The two annotations at the top are the only Windmill-specific parts — everything else is plain ADK.
- Python
- TypeScript
# sandbox
# volume: adk-state .adk
#requirements:
#google-adk
import asyncio
import os
from pathlib import Path
from typing import TypedDict
from google.adk.agents import Agent
from google.adk.runners import Runner
from google.adk.sessions.sqlite_session_service import SqliteSessionService
from google.genai import types
class googleai(TypedDict):
api_key: str
base_url: str
platform: str
def get_weather(city: str) -> dict:
"""Return the current weather for a given city.
Args:
city: The city name to look up.
"""
data = {
"paris": {"temp_c": 18, "conditions": "Sunny"},
"tokyo": {"temp_c": 22, "conditions": "Cloudy"},
"new york": {"temp_c": 12, "conditions": "Rainy"},
}
info = data.get(city.lower())
if info is None:
return {"status": "error", "message": f"No weather data for {city}."}
return {"status": "success", "city": city, **info}
root_agent = Agent(
name="weather_agent",
model="gemini-2.5-flash",
description="Assistant that answers questions about the current weather.",
instruction=(
"You are a friendly weather assistant. "
"Call the get_weather tool when the user asks about weather. "
"If a city is not supported, suggest one of: Paris, Tokyo, New York."
),
tools=[get_weather],
)
APP_NAME = "windmill_adk_demo"
USER_ID = "windmill_user"
STATE_DIR = Path(".adk")
SESSION_FILE = STATE_DIR / "session-id.txt"
DB_FILE = STATE_DIR / "sessions.db"
async def _run(prompt: str) -> dict:
STATE_DIR.mkdir(parents=True, exist_ok=True)
# SQLite-native service (REAL-typed timestamps, plain aiosqlite). ADK's
# generic DatabaseSessionService is SQLAlchemy-based and aimed at
# postgres/mysql; on SQLite it can fail on resume with
# `fromisoformat: argument must be str`.
session_service = SqliteSessionService(str(DB_FILE))
saved_id = SESSION_FILE.read_text().strip() if SESSION_FILE.exists() else None
session = None
if saved_id:
session = await session_service.get_session(
app_name=APP_NAME, user_id=USER_ID, session_id=saved_id
)
is_resume = session is not None
if not is_resume:
session = await session_service.create_session(
app_name=APP_NAME, user_id=USER_ID
)
SESSION_FILE.write_text(session.id)
runner = Runner(
agent=root_agent,
app_name=APP_NAME,
session_service=session_service,
)
new_message = types.Content(
role="user", parts=[types.Part.from_text(text=prompt)]
)
response = ""
async for event in runner.run_async(
user_id=USER_ID, session_id=session.id, new_message=new_message
):
if event.content and event.content.parts:
for part in event.content.parts:
if part.text:
response += part.text
return {
"is_resume": is_resume,
"session_id": session.id,
"prompt": prompt,
"response": response,
}
def main(gemini: googleai, prompt: str = "What's the weather in Paris?") -> dict:
# Script inputs are not env vars — set them explicitly.
# See "Passing credentials" below.
os.environ["GOOGLE_API_KEY"] = gemini["api_key"]
if gemini.get("platform") == "google_vertex_ai":
os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "true"
if gemini.get("base_url"):
os.environ["GOOGLE_GEMINI_BASE_URL"] = gemini["base_url"]
return asyncio.run(_run(prompt))
// sandbox
// volume: adk-state .adk
import { LlmAgent, FunctionTool, Runner, DatabaseSessionService } from '@google/adk'
import { createUserContent } from '@google/genai'
import { z } from 'zod'
import * as fs from 'node:fs'
import * as path from 'node:path'
const getWeather = new FunctionTool({
name: 'get_weather',
description: 'Get the current weather for a city.',
parameters: z.object({
city: z.string().describe('The city name.'),
}),
execute: async ({ city }: { city: string }) => {
const data: Record<string, { temp_c: number; conditions: string }> = {
paris: { temp_c: 18, conditions: 'Sunny' },
tokyo: { temp_c: 22, conditions: 'Cloudy' },
'new york': { temp_c: 12, conditions: 'Rainy' },
}
const info = data[city.toLowerCase()]
if (!info) return { status: 'error', message: `No weather data for ${city}.` }
return { status: 'success', city, ...info }
},
})
const rootAgent = new LlmAgent({
name: 'weather_agent',
model: 'gemini-2.5-flash',
description: 'Assistant that answers questions about the current weather.',
instruction:
'You are a friendly weather assistant. ' +
'Call the get_weather tool when the user asks about weather. ' +
'If a city is not supported, suggest one of: Paris, Tokyo, New York.',
tools: [getWeather],
})
const APP_NAME = 'windmill_adk_demo'
const USER_ID = 'windmill_user'
const STATE_DIR = '.adk'
const SESSION_FILE = path.join(STATE_DIR, 'session-id.txt')
const DB_FILE = path.join(STATE_DIR, 'sessions.db')
export async function main(
gemini: RT.Googleai,
prompt: string = "What's the weather in Paris?"
) {
// adk-js reads GEMINI_API_KEY (or GOOGLE_GENAI_API_KEY) — script inputs
// are not env vars, set them explicitly. See "Passing credentials" below.
process.env.GEMINI_API_KEY = gemini.api_key
process.env.GOOGLE_GENAI_API_KEY = gemini.api_key
if (gemini.platform === 'google_vertex_ai') {
process.env.GOOGLE_GENAI_USE_VERTEXAI = 'true'
}
if (gemini.base_url) {
process.env.GOOGLE_GEMINI_BASE_URL = gemini.base_url
}
fs.mkdirSync(STATE_DIR, { recursive: true })
const sessionService = new DatabaseSessionService(`sqlite://${DB_FILE}`)
const savedId = fs.existsSync(SESSION_FILE)
? fs.readFileSync(SESSION_FILE, 'utf-8').trim()
: undefined
let session = savedId
? await sessionService.getSession({
appName: APP_NAME,
userId: USER_ID,
sessionId: savedId,
})
: undefined
const isResume = !!session
if (!session) {
session = await sessionService.createSession({
appName: APP_NAME,
userId: USER_ID,
})
fs.writeFileSync(SESSION_FILE, session.id)
}
const runner = new Runner({
appName: APP_NAME,
agent: rootAgent,
sessionService,
})
let response = ''
for await (const event of runner.runAsync({
userId: USER_ID,
sessionId: session.id,
newMessage: createUserContent(prompt),
})) {
for (const part of event.content?.parts ?? []) {
if (part.text) response += part.text
}
}
return {
is_resume: isResume,
session_id: session.id,
prompt,
response,
}
}
Step 2: Run it
Click Test with a prompt like "What's the weather in Paris?" and your API key. You should see something like:
{
"is_resume": false,
"session_id": "51b81888-567e-4f46-9190-dff1e1c4eefd",
"prompt": "What's the weather in Paris?",
"response": "The weather in Paris is Sunny with a temperature of 18 degrees Celsius."
}
Now run it again with "And in Tokyo?". The script reads the saved session ID from the volume, resumes the same conversation, and the agent answers based on the prior turn:
{
"is_resume": true,
"session_id": "51b81888-567e-4f46-9190-dff1e1c4eefd",
"prompt": "And in Tokyo?",
"response": "Tokyo is cloudy at 22°C."
}
How it works
# sandbox / // sandbox
Wraps the job process in nsjail. The agent — and any subprocess it spawns (e.g. an MCP server or shell tool) — sees an isolated filesystem and cannot reach the worker's secrets or other jobs.
# volume: adk-state .adk / // volume: adk-state .adk
Mounts a persistent volume at ./.adk relative to the job's working directory. Files written there survive across runs and are synced to object storage. The script uses two files in the volume:
.adk/session-id.txt— the active ADK session ID, so the next run resumes the same conversation..adk/sessions.db— a SQLite database managed by ADK'sDatabaseSessionService, holding the full session history.
If you want a fresh conversation, delete the volume contents (or use a different volume name).
DatabaseSessionService
ADK ships several session services. InMemorySessionService loses state at the end of each job — useless across Windmill runs. DatabaseSessionService with a SQLite URL inside the volume gives you persistence with zero infrastructure.
The two ports do not use the same SQLite implementation:
- Python — use
SqliteSessionService(ingoogle.adk.sessions.sqlite_session_service). It's a SQLite-native service that stores timestamps asREALand goes throughaiosqlitedirectly, sidestepping a SQLAlchemy + aiosqlite type-marshalling bug that surfaces on resume asfromisoformat: argument must be str. The genericDatabaseSessionServiceis fine for postgres/mysql but should not be used with SQLite. - TypeScript —
DatabaseSessionServiceworks with asqlite://<path>URL via MikroORM. No extra dependency needed.
For multi-user or multi-tenant setups, scope the volume per workspace or input:
// volume: $workspace-adk-state .adk
See dynamic volume names for the full set of placeholders.
Passing credentials
Script inputs are language variables — they are not automatically exported as environment variables inside the sandbox. ADK reads its API key from the environment, so the scripts set it explicitly:
- Python (
google-adk) readsGOOGLE_API_KEY. - TypeScript (
@google/adk) readsGEMINI_API_KEYorGOOGLE_GENAI_API_KEY.
Apply the same pattern for any other credential the agent or its tools need at runtime — for example OPENAI_API_KEY, TAVILY_API_KEY, or an MCP server token.
The script also forwards the resource's optional platform and base_url fields:
platform: "google_vertex_ai"flips ADK to Vertex mode by settingGOOGLE_GENAI_USE_VERTEXAI=true. Vertex needsGOOGLE_CLOUD_PROJECTandGOOGLE_CLOUD_LOCATIONset on the worker (or wired through additional script inputs).base_urlis exported asGOOGLE_GEMINI_BASE_URLfor users running behind a proxy or against a non-default endpoint. Most users leave both fields empty.
Extending the agent
Use a different model
ADK is model-agnostic. Both ports support LiteLLM (Python) or compatible model wrappers (TypeScript) to swap the underlying provider. To run the same agent on Anthropic Claude in Python, replace the model= line:
from google.adk.models.lite_llm import LiteLlm
root_agent = Agent(
name="weather_agent",
model=LiteLlm("anthropic/claude-3-5-sonnet-20241022"),
...
)
Then export ANTHROPIC_API_KEY (or the relevant provider key) the same way as GOOGLE_API_KEY. If you already have a Windmill anthropic resource, accept it as a script input:
def main(anthropic: dict, prompt: str = "..."):
os.environ["ANTHROPIC_API_KEY"] = anthropic["apiKey"]
return asyncio.run(_run(prompt))
Compose multiple agents
ADK supports multi-agent systems out of the box — define sub-agents and assign them to a coordinator:
greeter = Agent(name="greeter", model="gemini-2.5-flash", instruction="...", description="...")
researcher = Agent(name="researcher", model="gemini-2.5-flash", instruction="...", description="...", tools=[...])
root_agent = Agent(
name="coordinator",
model="gemini-2.5-flash",
description="Routes user requests to the right specialist.",
sub_agents=[greeter, researcher],
)
The runner code stays unchanged — ADK handles the routing and tool calls.
Add MCP tools
ADK can consume any Model Context Protocol server via MCPToolset. Because the sandbox isolates subprocesses, you can safely launch MCP servers inside the same job:
from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset, StdioServerParameters
tools = await MCPToolset.from_server(
connection_params=StdioServerParameters(command="npx", args=["@modelcontextprotocol/server-filesystem", "."])
)
Pair this with a separate volume for the MCP server's working directory if it needs persistent state.
Use the agent inside a flow
Drop this script into any flow step and chain it with other Windmill primitives — fetch context from a database, call the agent, then post the response to Slack. The volume keeps the agent's memory consistent across flow runs.
Troubleshooting
#sandboxannotation but nsjail is not available — your worker image does not include nsjail. Use a standard Windmill Docker image or remove the annotation.- Volume not persisting — check that workspace object storage is configured under instance settings. Without it, volumes only exist for the duration of a single job.
fromisoformat: argument must be str(Python, on resume) — you usedDatabaseSessionServiceagainst SQLite, which hits a SQLAlchemy + aiosqlite type-marshalling bug. Switch toSqliteSessionServicefromgoogle.adk.sessions.sqlite_session_service(the snippet above already does this) and delete the old.adk/sessions.db.API key must be provided via constructor or GOOGLE_GENAI_API_KEY or GEMINI_API_KEY(TypeScript) — the JS port readsGEMINI_API_KEY/GOOGLE_GENAI_API_KEY, notGOOGLE_API_KEY. Set both for portability.