The core SDK
swarmd-sdk is the foundation both framework wrappers are built on. It has no
opinion about how you write your agent — it only handles the parts that touch
Swarmd.
Use it directly when:
- You’re using a framework we don’t wrap yet (CrewAI, Autogen, raw OpenAI, your own loop).
- You want a plain API client — a script, a CI job, a backend service that lists agents or calls an MCP tool.
- You’re using ADK or LangChain but need to deviate from what the wrapper does.
Using Google ADK or LangChain? The wrapper is strictly less work — start at
Google ADK or LangChain
and come back here when you need to drop a level.
Assumes you’ve finished Setup — a registered agent and a
.env with your three secrets.Part 1 — Authenticate and discover
The smallest useful program: who am I subscribed to?discover.py
Dissecting it
SwarmDClient.from_env() — where config comes from
SwarmDClient.from_env() — where config comes from
Reads
SWARMD_AGENT_ID and SWARMD_CLIENT_SECRET (both required, raises
ValueError if missing), plus four optional overrides:Running two agents in one process? Pass a prefix:
SwarmDClient.from_env("WEATHER_AGENT") reads WEATHER_AGENT_AGENT_ID and so
on.Prefer explicit config? SwarmDClient(agent_id=..., client_secret=...) or
SwarmDClient.from_config(SwarmDConfig(...)).The with block — why it matters
The with block — why it matters
SwarmDClient holds a pooled httpx.Client. The context manager closes it on
exit. In a long-lived service you’d build the client once at startup and close
it at shutdown instead — just don’t create one per request; you’d throw away
the connection pool and the cached token every time.What happened on the wire
What happened on the wire
You wrote one line. The SDK did five things:
POSTto the token endpoint withgrant_type=client_credentials,scope=swarmd:api,resource={base_url}— the RFC 8707 parameter that binds the token’s audience to the gateway.- Cached the token in memory, marked expired 60s before its real expiry.
GET /registry/v1/agents/{id}/subscriptions?page=0&size=100withAuthorization: Bearer ….- Followed pagination until
totalPageswas exhausted (capped at 100 pages). - Validated each row into an
AgentResponsepydantic model.
401 it would have cleared the token and retried once. On a 5xx or a
network error, retried with exponential backoff up to max_retries. On any
other 4xx, failed immediately — retrying a 403 doesn’t help anyone.Handling failure
SwarmDSDKError → AuthenticationError → TokenRefreshError,
and SwarmDSDKError → APIError. Catch SwarmDSDKError to catch everything
the SDK raises.
Part 2 — Call an MCP server
MCP servers are granted to your agent the same way sub-agents are.McpClient
discovers them and proxies JSON-RPC through the relay.
mcp_demo.py
Dissecting it
Why a separate client from SwarmDClient?
Why a separate client from SwarmDClient?
Because it needs a different token.
McpClient mints mcp:call-scoped tokens
bound to {base_url}/relay; the relay’s MCP path rejects the swarmd:api
token SwarmDClient carries. Same credentials, different audience — see
two tokens, not one.Everything else mirrors SwarmDClient: same constructor arguments, same
from_env / from_config, same retry semantics, same context manager.list_available() vs. the relay
list_available() vs. the relay
Discovery hits the registry
(
GET /registry/v1/agents/{id}/mcp-subscriptions), because the registry is the
source of truth for grants. The relay only enforces grants on the call path.An agent principal is constrained to its own id server-side, so you can’t
enumerate another agent’s grants with your token.call() — dropping to raw JSON-RPC
call() — dropping to raw JSON-RPC
list_tools() and call_tool() are conveniences over call(). For anything
else the MCP spec defines — resources/list, prompts/get, initialize —
use call() directly:uuid4 request id if you
don’t supply one, and returns the result field. A JSON-RPC error object in
the response is raised as McpJsonRpcError with .code, .message and
.data.McpNotSubscribedError — the useful 403
McpNotSubscribedError — the useful 403
A
403 from the relay’s MCP proxy path specifically means you don’t have a
grant for this server, and is raised as McpNotSubscribedError (a subclass of
APIError) so you can distinguish it from generic failure:403s from other paths stay as plain APIError — the SDK deliberately doesn’t
paper over an RBAC gap on the registry side by calling it a subscription
problem.Part 3 — Keep discovery fresh
Grants change while your agent runs.AgentDirectory polls in the background
and hands you an atomically-swapped snapshot.
start() refreshes once synchronously before spawning the thread, so
directory.agents is populated the moment the block opens. Refresh failures
are logged and swallowed — a registry blip leaves the previous snapshot in
place rather than emptying your catalogue.
Part 4 — Serve an agent
Here’s the part the wrappers otherwise hide: a complete, self-serving agent using nothing butswarmd-sdk and Starlette.
main.py
Dissecting it
Why configure() rather than passing config to a constructor
Why configure() rather than passing config to a constructor
So credentials can arrive after boot. If you deploy an agent before its
service account exists, leave the runtime unconfigured —
runtime.configured
is False, the agent still serves its card and health check, and an operator
later does:configure() builds fresh clients, swaps them in under a lock, and closes the
old ones. base_url and token_url are required on the first configure and
optional on subsequent ones, so a credential rotation only needs the two
secrets.create_admin_app(runtime, on_refresh=refresh)
create_admin_app(runtime, on_refresh=refresh)
Returns a FastAPI app with the four
/admin endpoints. on_refresh is the
only thing you supply — it’s called by both POST /admin/refresh and by
POST /admin/webhook after the HMAC verifies.Your callback runs while holding the runtime’s lock, so it can’t race a
concurrent configure(). It should be idempotent: the same webhook can arrive
twice, and the SDK acks duplicates rather than trying to dedupe them.Omit on_refresh and /admin/refresh returns 501, while a verified webhook
returns {"status": "ignored"} — acked, so the platform’s dispatcher doesn’t
dead-letter a legitimate delivery.Mount order: middleware before mount
Mount order: middleware before mount
CorrelationIdMiddleware is added to the outer Starlette app, so it wraps
everything including the mounted admin app. It reads X-Correlation-Id off the
inbound request into a ContextVar, and resets it in a finally so requests
don’t leak ids into each other.Every outbound client the SDK builds reads that same var and forwards the
header — which is what makes one user request show up as one trace across
five agents in the audit log.Push your card to the registry on boot
Push your card to the registry on boot
The registry caches your agent card from registration time. If your
description or skills have drifted, ask it to re-read:Call it with your own agent id — the registry compares it against the JWT
subject and rejects a cross-agent refresh with
403. Treat failure as
non-fatal: a registry blip at boot must not stop your agent serving.swarmd-google-adk does this for you inside create_llm_agent().
swarmd-langchain does not — call it yourself if you want it.Part 5 — Call a sub-agent over A2A
The wrappers turn each subscription into a framework-native tool. Without a wrapper, you drive the A2A protocol yourself. Here’s the minimum, so you can see exactly what’s being automated for you.call_subagent.py
401 retry, a wait cap, and conversion into
whatever tool shape your LLM expects. Multiply by every subscription and
that’s the argument for the wrappers.
What you’re doing by hand
Everything on this page, side by side with what a wrapper would have done:Next
Reference
Every class, method, and environment variable in
swarmd-sdk.Troubleshooting
The failure modes you’ll actually hit, and what they mean.
