Skip to main content

Google ADK on SwarmD

swarmd-google-adk is a thin wrapper on top of Google ADK that gives a Google ADK LlmAgent access to the SwarmD platform: OAuth2 authentication, sub-agent discovery, MCP tool discovery, polling for long-running sub-agents, an A2A server, signed-webhook refresh, and an admin surface for operators. If you are already running a Google ADK agent and want to move it onto SwarmD, read this first to learn the helpers, then follow Migrating from Google ADK. If you are starting from scratch this page is the whole story.

What it gives you

The SDK exposes three helpers that, in order, produce a complete agent:
HelperReturnsWhat it does
create_runtime()SwarmDRuntimeReads SWARMD_* env vars, configures a runtime, and instantiates token managers (platform-API + MCP-relay).
create_llm_agent(runtime, ...)Google ADK LlmAgentPicks the LLM provider, discovers subscribed sub-agents, discovers subscribed MCP servers, wires both into the LlmAgent’s sub_agents and tools.
serve(agent, runtime)runs foreverMounts the A2A protocol, the agent card, the /admin surface, correlation middleware, and starts uvicorn. Refreshes the catalogue in place on signed webhooks.
The full agent file is usually 20–30 lines: a tool function, three calls, and an if __name__ == "__main__" guard.

Installation

pip install swarmd-google-adk
That pulls in swarmd-sdk as a transitive dependency plus google-adk, a2a-sdk, and the relay HTTP client.

Credentials

Every agent registered on SwarmD receives three secrets:
  • agentId — the agent’s UUID and OAuth2 client ID.
  • clientSecret — used for outbound calls (registry, A2A relay, MCP, LLM gateway).
  • webhookSecret — used by the platform to sign inbound webhooks to your agent’s /admin/webhook endpoint.
All three are shown once in the dashboard or in the POST /registry/v1/agents response. Store them with your deployment configuration. The webhook secret is technically optional — outbound calls still work without it — but without it your agent has to be manually refreshed (POST /admin/refresh) every time a subscription or grant changes upstream.

Environment

.env
# LLM provider — pick one (gateway preferred)
SWARMD_LLM_GATEWAY_ID=     # UUID of a configured LLM gateway, or
OPENAI_API_KEY=sk-...      # direct OpenAI fallback
OPENAI_MODEL=gpt-4o        # optional, default gpt-4o
OPENAI_TEMPERATURE=0       # optional; when set, enables deterministic sampling

# SwarmD platform credentials
SWARMD_AGENT_ID=00000000-0000-0000-0000-000000000000
SWARMD_CLIENT_SECRET=...
SWARMD_WEBHOOK_SECRET=...
SWARMD_BASE_URL=https://api.swarmd.ai
SWARMD_TOKEN_URL=https://auth.swarmd.ai/realms/swarmd/protocol/openid-connect/token

# Server (optional)
HOST=0.0.0.0
PORT=8080
LOG_LEVEL=INFO
The four SWARMD_AGENT_ID / SWARMD_CLIENT_SECRET / SWARMD_BASE_URL / SWARMD_TOKEN_URL values are the platform-attached set. Leave any of them unset and the runtime stays in “standalone mode” — the agent still runs locally, it just won’t see any subscribed sub-agents or MCP tools. This is useful for local development without a SwarmD account.

The whole agent, end to end

main.py
from dotenv import load_dotenv
load_dotenv()

from swarmd_google_adk import create_llm_agent, create_runtime, serve


def get_time(city: str) -> str:
    """Get the current time for any city."""
    return f"The time in {city} is 18:00"


runtime = create_runtime()
agent = create_llm_agent(
    runtime,
    name="time_agent",
    description="A simple agent that provides time information",
    instruction=(
        "You are a helpful time agent. Use the get_time tool to tell users "
        "the time in any city they ask about. You can also collaborate with "
        "other agents and call MCP tools for additional capabilities."
    ),
    tools=[get_time],
)


if __name__ == "__main__":
    serve(agent, runtime)
Run it:
python3 main.py
You should see a banner with the agent name and bind address, then — once the runtime is configured — Found N subscribed agents and Loaded N MCP toolset(s) in the logs.

The three helpers, in detail

create_runtime()

Builds a SwarmDRuntime object and, if the four SWARMD_* vars are set, calls runtime.configure(...). That instantiates:
  • A platform-API SwarmDClient for registry and audit calls.
  • An McpClient for MCP discovery.
  • Two TokenManagerProxy objects — one for the platform-API audience (swarmd:api), one for the MCP relay audience (mcp:call). Both do double-check-locked OAuth2 client-credentials grants with caching, jittered backoff retries on 5xx, and a single 401 retry.
You never instantiate or thread these directly — every other helper grabs them off the runtime when it needs to mint a bearer.

create_llm_agent(runtime, name, description, instruction, tools, *, generate_content_config=None)

Returns a regular Google ADK LlmAgent you can pass around like any other ADK agent. It does five things on top of LlmAgent(...):
  1. Picks the LLM provider.
    • If SWARMD_LLM_GATEWAY_ID is set, LiteLlm is built with api_base pointed at ${SWARMD_BASE_URL}/llm/v1/${id} and api_key set to the agent’s bearer — every LLM call flows through the relay and shows up in audit. No provider API key needed.
    • If only OPENAI_API_KEY is set, LiteLlm goes direct to OpenAI.
    • If neither is set the call raises ValueError rather than booting in a broken state.
  2. Discovers sub-agents. Calls fetch_remote_agents(runtime), which pulls subscriptions from GET /registry/v1/agents/{id}/subscriptions and wraps each one in a PollingRemoteA2aAgent. The polling wrapper is the important bit: when a sub-agent returns a non-terminal task state (working, submitted, input_required, auth_required) the wrapper polls tasks/get against the relay until it reaches a terminal state, then surfaces the final result to the parent LLM as a normal Event. ADK’s stock RemoteA2aAgent just returns the non-terminal payload, which the LLM can’t do anything useful with.
  3. Discovers MCP servers. Calls fetch_mcp_tools(runtime), which lists MCP grants via the SDK’s McpClient, then builds one ADK McpToolset per granted server pointed at the relay’s MCP proxy (/relay/v1/mcp-servers/{id}/mcp). Tools are namespaced with a sanitised, UUID-seeded prefix so a free-form server name like "GitLab - swarmd.ai" can’t produce a tool name the LLM provider rejects. A header provider mints a fresh mcp:call-scoped token on every request and propagates the current correlation ID.
  4. Honours OPENAI_TEMPERATURE. When set, it becomes a GenerateContentConfig(temperature=...) automatically. Pass generate_content_config=... to override.
  5. Composes the catalogue. Local tools you passed in + remote sub-agents (as PollingRemoteA2aAgent in sub_agents) + MCP toolsets (as McpToolset entries in tools).
If you want fine-grained control — your own header injector on the MCP client, a custom polling interval, hand-built RemoteA2aAgents — drop down to create_runtime, fetch_remote_agents, and fetch_mcp_tools directly and build the LlmAgent yourself. They are all exported.

serve(agent, runtime)

The thickest of the three helpers because the A2A server is the agent’s public face. In order, it:
  1. Mounts the A2A protocol. Builds an ADK AgentCardBuilder, serialises the card at /.well-known/agent-card.json, attaches A2AStarletteApplication (which owns message/send, message/stream, tasks/get, tasks/cancel, etc.), and wires A2aAgentExecutor so the protocol calls reach your LlmAgent.
  2. Persists tasks and sessions. A SQLite-backed DatabaseTaskStore and DatabaseSessionService are created at /tmp/{agent}_tasks.db and /tmp/{agent}_sessions.db. Enough for local dev; in production point them at a real database by overriding the create calls inside your own copy of serve if you need durability across pod restarts.
  3. Adds correlation propagation. A CorrelationIdMiddleware extracts X-Correlation-Id off inbound A2A calls into a ContextVar, and the SDK’s outbound interceptors copy that var onto every downstream bearer-tokened request. One request through the relay shows up as one trace ID end-to-end in the audit log.
  4. Mounts /admin. Four endpoints — POST /admin/configure, POST /admin/refresh, POST /admin/webhook, GET /admin/status. /admin/webhook is the platform-driven kick: when a subscription, MCP grant, agent lifecycle, or tenant lifecycle event fires, the platform POSTs a signed payload, the SDK verifies the HMAC against SWARMD_WEBHOOK_SECRET, and calls the same refresh path as /admin/refresh.
  5. Refreshes in place. The refresh callback re-runs fetch_remote_agents and fetch_mcp_tools, then swaps the LlmAgent’s sub_agents and tools lists atomically. Local Python tools you passed into create_llm_agent are preserved across the swap. No restart needed when subscriptions change.
  6. Starts uvicorn on HOST:PORT (defaults 0.0.0.0:8080) with LOG_LEVEL from env.

Verifying it works

# Agent card with discovered sub-agents and tools
curl http://localhost:8080/.well-known/agent-card.json

# A2A message via JSON-RPC
curl -X POST http://localhost:8080/ \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0", "id": 1, "method": "message/send",
    "params": {
      "message": {
        "role": "user",
        "parts": [{"kind": "text", "text": "What time is it in Tokyo?"}],
        "messageId": "11111111-1111-1111-1111-111111111111"
      }
    }
  }'

# Admin status (configured? agent_id?)
curl http://localhost:8080/admin/status
If the agent card response is missing sub-agents or MCP tools you expect to see, check that the subscription is live in the dashboard and that Found N subscribed agents / Loaded N MCP toolset(s) appear in the agent’s startup logs.

Advanced — using the SDK pieces directly

If you need to deviate from what create_llm_agent and serve do, drop down to the underlying pieces. The whole surface area is exported:
from swarmd_google_adk import (
    create_runtime,
    fetch_remote_agents,   # returns List[PollingRemoteA2aAgent]
    fetch_mcp_tools,        # returns List[BaseToolset]
    PollingRemoteA2aAgent,  # ADK RemoteA2aAgent + polling
    create_a2a_client_factory,
)
from swarmd_sdk import create_admin_app, SwarmDRuntime
Common reasons to drop down:
  • Custom polling interval / max wait. Build PollingRemoteA2aAgent instances yourself with poll_interval= and max_wait=.
  • Hand-built LlmAgent config. Use the discovery functions to get the lists, then assemble LlmAgent(...) with whatever extra callbacks, structured-output config, or custom tools you need.
  • Custom A2A server. Use create_admin_app(runtime, on_refresh=...) for the /admin surface and write your own Starlette / FastAPI bootstrap around it. The refresh callback is yours to define.

Reference implementation

See the time_agent in the repo — a complete agent in ~30 lines of main.py. It is the canonical “smallest working agent” example and the file you should diff against when you want to know what a clean migration looks like.

Next steps