Skip to main content

Google ADK on Swarmd

swarmd-google-adk wraps Google ADK so an LlmAgent can live on Swarmd: authenticated, discovering its sub-agents and MCP tools, served over A2A, and refreshing itself when grants change. Three helpers do all of it.
Assumes you’ve finished Setup. Ten minutes on Core concepts will make the rest of this page obvious rather than magical — but it isn’t required.

Step 1 — Install

That pulls in swarmd-sdk, google-adk[a2a,extensions], a2a-sdk, mcp, and litellm. Use a virtualenv — the version bounds are deliberately tight.

Step 2 — Add your LLM to .env

On top of the credentials from Setup, pick a model provider:
Set neither and create_llm_agent() raises ValueError the moment it runs — at import time in the layout below, where the call sits at module level. That’s deliberate: booting into a state where every LLM call fails is worse than failing loudly at startup.

Step 3 — Write the agent

The whole file:
main.py
load_dotenv() must run before the swarmd_google_adk import. Module import reads environment variables; a .env loaded afterwards is too late. That’s why the import sits below the call instead of at the top of the file, against every linter’s advice. Keep it that way.
Your docstring is the tool spec. ADK sends the function’s name, signature, and docstring to the model as the tool definition. A vague docstring is a tool the model won’t use correctly. Describe the arguments.

Step 4 — Run it

You’re looking for four things in the output:
create_runtime() only configures when all four of SWARMD_AGENT_ID, SWARMD_CLIENT_SECRET, SWARMD_BASE_URL and SWARMD_TOKEN_URL are set. Miss one and you get standalone mode: the agent runs, but discovers nothing.It’s a deliberate escape hatch for offline development, and the single most common cause of “why is my catalogue empty?”. Check all four.

Step 5 — Talk to it

That’s a working agent. The rest of this page explains what those three calls did.

Dissected: create_runtime()

Constructs a SwarmDRuntime and, if the four platform env vars are present, calls runtime.configure(...) with them — plus SWARMD_WEBHOOK_SECRET if set. That single call builds:
  • A SwarmDClient for registry calls.
  • An McpClient for MCP discovery and invocation.
  • Two token manager proxies — swarmd:api for platform calls, mcp:call for the MCP relay. See two tokens, not one.
You never touch these directly. Every other helper reaches onto the runtime for whatever it needs.

Dissected: create_llm_agent()

It returns an ordinary ADK LlmAgent — pass it around, wrap it, inspect it like any other. Five things happen on the way there.
1

Picks the LLM provider

Checked in order:
  1. SWARMD_LLM_GATEWAY_ID set → LiteLLM is pointed at {SWARMD_BASE_URL}/llm/v1/{gateway_id}, wrapped in an AsyncOpenAI whose HTTP client mints a fresh Swarmd bearer on every request and evicts the cached token on a 401. No provider key needed.
  2. OPENAI_API_KEY set → LiteLLM goes direct to OpenAI.
  3. Neither → ValueError.
If SWARMD_LLM_GATEWAY_ID is set but the runtime is unconfigured — the credentials haven’t been issued yet on a brand-new agent’s first deploy — the helper prints a WARN and builds a placeholder LlmAgent instead of crashing.That’s on purpose: the pod boots, the readiness probe passes, registry discovery succeeds, and the agent serves its card. Live LLM calls would fail in that state, but a fresh agent registers as PRIVATE and shouldn’t be taking traffic yet. The next deploy with real credentials builds a properly routed agent.Without that guard the pod would CrashLoopBackOff, Helm would roll back, and the deploy would go red — over an agent that didn’t need an LLM call to serve its card.
2

Discovers sub-agents

Calls fetch_remote_agents(runtime), which pulls GET /registry/v1/agents/{id}/subscriptions and wraps each result in a PollingRemoteA2aAgent.The polling is the point. ADK’s stock RemoteA2aAgent returns whatever the sub-agent replied with — including a non-terminal working state, which the parent LLM can do nothing useful with. PollingRemoteA2aAgent detects non-terminal states and polls tasks/get through the relay (every 5s, up to 600s) until the task is terminal, then surfaces the real answer as a normal ADK Event.Each wrapped agent gets an authenticated ClientFactory: a JSON-RPC transport with a token-injecting interceptor and a response hook that adopts the relay’s correlation ID for the rest of the chain.
3

Discovers MCP servers

Calls fetch_mcp_tools(runtime), which lists your grants and builds one ADK McpToolset per server, pointed at the relay’s proxy (/relay/v1/mcp-servers/{id}/mcp).Two details worth knowing:
  • Auth goes through a custom httpx_client_factory, not ADK’s header_provider. ADK skips header_provider when the context is None, which is exactly the case for the catalogue-build request it issues at boot — so the boot request would go out unauthenticated, the relay would 401, and the toolset would be silently dropped. The client factory fires on every request, including that one.
  • Tools are namespaced with mcp_tool_namespace(server) so a server called "GitLab - swarmd.ai" can’t produce a tool name OpenAI rejects with 400.
Timeouts are split — 10s connect, 300s read — because Streamable HTTP holds long-lived SSE streams for tool calls, and a flat 30s budget cuts them short.
4

Applies sampling config

If generate_content_config is None and OPENAI_TEMPERATURE is set, it becomes GenerateContentConfig(temperature=...). Pass generate_content_config= explicitly to control the rest.tool_choice= forwards OpenAI’s tool-calling policy to every LLM turn:
"required" cures tool-shy models that reply “Let me check…” instead of actually delegating. Only use it on agents whose reply path always terminates in a tool call — otherwise the ADK completion loop can never halt.
5

Composes the catalogue

tools=list(your_tools) + mcp_toolsets, sub_agents=remote_agents.Discovery failures here are non-fatal and logged — an agent that can’t reach the registry at boot still starts with its local tools rather than refusing to run.

Dissected: serve()

The thickest helper, because the A2A server is your agent’s public face.
1

Mounts the A2A protocol

Builds the agent card with ADK’s AgentCardBuilder, serves it at /.well-known/agent-card.json, and attaches A2AStarletteApplication — which owns message/send, message/stream, tasks/get, tasks/cancel and the push-notification config endpoints. A2aAgentExecutor routes those calls into your LlmAgent.
2

Persists tasks and sessions

SQLite at /tmp/{agent_name}_tasks.db and /tmp/{agent_name}_sessions.db.
/tmp does not survive a pod restart. Fine for development and for agents whose tasks are short. If you need durable task state across restarts, copy serve() into your own module and point DatabaseTaskStore and DatabaseSessionService at a real database — they take any SQLAlchemy async URL.
3

Adds correlation propagation

CorrelationIdMiddleware captures inbound X-Correlation-Id into a ContextVar; every outbound client copies it forward. One user request, one trace id, across every hop.
4

Mounts /admin

POST /admin/configure, POST /admin/refresh, POST /admin/webhook, GET /admin/status./admin/webhook is the platform-driven kick: Swarmd POSTs a signed payload when a subscription, MCP grant, or lifecycle event fires; the SDK verifies the HMAC against SWARMD_WEBHOOK_SECRET and calls the refresh handler.Swarmd derives the webhook URL from your agent card URL by replacing /.well-known/agent-card.json with /admin/webhook — mounting at /admin is what makes that line up.
5

Refreshes in place

The refresh callback re-runs fetch_remote_agents and fetch_mcp_tools, then swaps agent.sub_agents and agent.tools.Your local Python tools survive: serve() snapshots them at startup by filtering out anything that’s a BaseToolset, and rebuilds the combined list from that snapshot each time. No restart when subscriptions change.
6

Starts uvicorn

On HOST:PORT (default 0.0.0.0:8080) at LOG_LEVEL (default info).

Going off the rails

Everything is exported, so you can drop a level wherever you need to:
With no a2a_client_factory, it builds an authenticated one from the active runtime.
Use the discovery functions for the lists, then assemble the agent yourself with whatever callbacks, structured output, or planner config you need:
The refresh callback is yours to define — it’s the only thing create_admin_app needs from you.

Next

Migrating an existing ADK agent

Diff-shaped guide: what to delete from your current main.py.

Reference

Every helper, argument, and environment variable.

LangChain

The same three helpers, for LangGraph.

Troubleshooting

Empty catalogues, 403s on MCP, webhooks that never fire.