> ## Documentation Index
> Fetch the complete documentation index at: https://docs.swarmd.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Core concepts

> The six ideas behind every helper in the Python SDKs — runtime, token audiences, subscriptions, MCP grants, polling, and refresh.

# Core concepts

Every helper in the Python SDKs is a thin wrapper over one of six ideas. Learn
them once here and the rest of the documentation reads as reference rather
than magic.

If you'd rather see code first, go build something and come back:
[Core SDK](/sdks/python/core-sdk) ·
[Google ADK](/sdks/python/google-adk) ·
[LangChain](/sdks/python/langchain).

***

## The shape of it

```mermaid theme={null}
flowchart LR
    subgraph yours["Your process"]
        agent["Your agent<br/>(ADK / LangGraph / anything)"]
        rt["SwarmDRuntime"]
        admin["/admin endpoints"]
    end

    subgraph platform["Swarmd"]
        kc["Keycloak<br/>token endpoint"]
        reg["Registry"]
        relay["Relay"]
        gw["LLM gateway"]
        audit["Audit"]
    end

    sub["Sub-agents"]
    mcp["MCP servers"]

    agent --> rt
    rt -->|"client credentials"| kc
    rt -->|"who am I subscribed to?"| reg
    agent -->|"A2A message/send"| relay
    agent -->|"MCP JSON-RPC"| relay
    agent -->|"chat completions"| gw
    relay --> sub
    relay --> mcp
    relay -.->|"every call"| audit
    gw -.->|"every call"| audit
    reg -->|"signed webhook"| admin
    admin --> rt
```

The one-sentence version: **your agent never talks to a sub-agent, an MCP
server, or an LLM provider directly — it talks to Swarmd, which checks the
grant, forwards the call, and writes the audit row.**

That indirection is the whole product. It's also why the SDK exists: without
it you'd be hand-rolling OAuth2 against two different audiences and rewriting
every downstream URL yourself.

***

## 1. The runtime holds your identity

`SwarmDRuntime` is the object everything else hangs off. It owns your
credentials and the clients built from them.

```python theme={null}
from swarmd_sdk import SwarmDRuntime

runtime = SwarmDRuntime()
runtime.configure(
    agent_id="0c6eab9a-...",
    client_secret="ka5Qqf0O...",
    base_url="https://api.dev.swarmd.ai",
    token_url="https://auth.dev.swarmd.ai/realms/swarmd/protocol/openid-connect/token",
    webhook_secret="9f7e1c8d...",
)
```

After `configure()` the runtime exposes:

| Attribute                   | What it is                                                    |
| --------------------------- | ------------------------------------------------------------- |
| `runtime.client`            | `SwarmDClient` — registry calls (subscriptions, card refresh) |
| `runtime.mcp`               | `McpClient` — MCP discovery and JSON-RPC through the relay    |
| `runtime.token_manager`     | Mints platform-API bearers                                    |
| `runtime.mcp_token_manager` | Mints MCP-relay bearers                                       |
| `runtime.configured`        | `False` until `configure()` succeeds                          |

The framework wrappers give you `create_runtime()`, which does the same thing
from environment variables.

<Note>
  **Why "configure later" rather than a constructor?** Because credentials can
  arrive after boot. `POST /admin/configure` lets an operator inject them into a
  running agent, and the runtime swaps its clients in place — no restart. That's
  also why `token_manager` is a *proxy*: helpers grab it once at startup and it
  keeps working across a reconfigure.
</Note>

***

## 2. Two tokens, not one

This is the concept that trips people up most, so it's worth the paragraph.

Your agent holds **one** OAuth2 client credential (`agent_id` +
`client_secret`), but it exchanges that credential for **two different kinds of
access token**, bound to two different audiences:

|                     | Platform-API token                      | MCP-relay token                       |
| ------------------- | --------------------------------------- | ------------------------------------- |
| Scope               | `swarmd:api`                            | `mcp:call`                            |
| RFC 8707 `resource` | `{base_url}`                            | `{base_url}/relay`                    |
| Used for            | Registry, audit, A2A relay, LLM gateway | `POST /relay/v1/mcp-servers/{id}/mcp` |
| Minted by           | `runtime.token_manager`                 | `runtime.mcp_token_manager`           |

They are **not interchangeable**. The relay's MCP path enforces a path-scoped
audience check; present a `swarmd:api` token there and you get `403`, not a
helpful error about scopes.

<Warning>
  If you're writing your own MCP transport rather than using `McpClient` or a
  wrapper's `fetch_mcp_tools`, this is the mistake you will make. Use
  `runtime.mcp_token_manager` for anything under `/relay/v1/mcp-servers/`.
</Warning>

Token handling itself is done for you: both managers cache the token, treat it
as expired 60 seconds early, refresh under a double-checked lock so concurrent
requests don't stampede the token endpoint, and evict the cache on a `401` so
the next call re-mints instead of looping on a stale token.

***

## 3. Subscriptions are your agent's address book

Your agent cannot call an arbitrary agent. It can only call agents it has been
**subscribed** to — a grant an operator creates in the dashboard or via the
registry API.

Both ends of a grant have to be bootstrapped first. The registry rejects a
subscription whose source or sink is still an agent shell with no card
attached — see [Setup, step 7](/sdks/python/setup#step-7-attach-your-agent-card).

```python theme={null}
agents = runtime.client.get_agent_subscriptions(my_agent_id)
# [AgentResponse(name='weather_agent', agent_card_url='https://api.dev.swarmd.ai/relay/...'), ...]
```

Two things to notice:

1. **The card URL is a relay URL, not the sub-agent's real address.** You never
   learn where the other agent actually lives. Calls go through the relay,
   which enforces the grant and records the hop.
2. **This is a live list, not config.** Grants change while your agent is
   running. See [refresh](#6-refresh-keeps-the-catalogue-live).

The framework wrappers turn each entry into a native tool — an ADK
`sub_agent`, a LangChain `BaseTool` — so your LLM sees subscribed agents in
the same catalogue as local functions.

<Accordion title="Keeping the list fresh without a webhook: AgentDirectory">
  `AgentDirectory` polls subscriptions on a background thread and hands you an
  immutable snapshot:

  ```python theme={null}
  from swarmd_sdk import AgentDirectory

  with AgentDirectory(client, my_agent_id, refresh_interval=30) as directory:
      weather = directory.get("weather_agent")
      for a in directory.agents:
          print(a.name)
  ```

  The list is a tuple that gets swapped atomically, so an in-flight task always
  sees a consistent view. Pass `on_change=` to be told which ids were added or
  removed. Use it when you want polling instead of — or as belt and braces
  alongside — webhook-driven refresh.
</Accordion>

***

## 4. MCP servers are granted the same way

[MCP](https://modelcontextprotocol.io/) servers work like sub-agents: an
operator grants your agent access, and the SDK discovers what it has.

```python theme={null}
servers = runtime.mcp.list_available()
tools = runtime.mcp.list_tools(servers[0].mcp_server_id)
result = runtime.mcp.call_tool(
    servers[0].mcp_server_id, "search", {"query": "weather"}
)
```

Discovery hits the **registry** (source of truth for grants); calls go through
the **relay** (which enforces the grant on every request and writes the audit
row). Both legs are handled by `McpClient`.

### Tool namespacing

MCP server names are free-form: `"GitLab - swarmd.ai"`. Tool names, as far as
OpenAI is concerned, must match `^[a-zA-Z0-9_-]+$` — anything else fails the
whole completion with `400`.

So the SDK derives a safe namespace per server:

```python theme={null}
from swarmd_sdk import mcp_tool_namespace

mcp_tool_namespace(server)
# "mcp_GitLab_-_swarmd_ai_15190c09"
```

Disallowed characters become `_`, and the first 8 hex digits of the server
UUID are appended so two servers whose names sanitise identically still get
distinct prefixes. Both wrappers apply this automatically — you'll see it in
your tool names, and now you know why.

***

## 5. Polling: sub-agents that take their time

A2A tasks are asynchronous. When your agent sends `message/send` to a
sub-agent, the response may come back in a **non-terminal** state:

| Non-terminal                                              | Terminal                                                 |
| --------------------------------------------------------- | -------------------------------------------------------- |
| `working`, `submitted`, `input_required`, `auth_required` | `completed`, `failed`, `canceled`, `rejected`, `unknown` |

Stock A2A clients return that non-terminal payload immediately — and an LLM
handed `{"state": "working"}` will cheerfully report back "the sub-agent is
working on it" and stop. Which is useless.

Both wrappers solve this with a polling wrapper — `PollingRemoteA2aAgent`
(ADK) and `PollingA2aTool` (LangChain). On a non-terminal response they poll
`tasks/get` against the relay until the task reaches a terminal state, then
return the final content:

* **Poll interval:** 5 seconds
* **Max wait:** 600 seconds (10 minutes)

Both are constructor arguments if you build the wrappers yourself.

***

## 6. Refresh keeps the catalogue live

Your agent discovers its sub-agents and MCP tools at boot. Grants change after
boot. Without something in between, you'd be restarting pods every time an
operator ticks a box.

```mermaid theme={null}
sequenceDiagram
    participant Op as Operator
    participant Reg as Registry
    participant Ag as Your agent
    Op->>Reg: grant agent access to weather_agent
    Reg->>Ag: POST /admin/webhook (HMAC-signed)
    Ag->>Ag: verify signature vs SWARMD_WEBHOOK_SECRET
    Ag->>Reg: re-discover subscriptions + MCP grants
    Ag->>Ag: swap tool catalogue in place
```

The SDK's `create_admin_app()` mounts four endpoints at `/admin`:

| Endpoint                | Purpose                                                                        |
| ----------------------- | ------------------------------------------------------------------------------ |
| `POST /admin/webhook`   | Signed kick from the platform. Verifies HMAC, then calls your refresh handler. |
| `POST /admin/refresh`   | Same refresh, triggered manually. The fallback when webhooks aren't wired.     |
| `POST /admin/configure` | Inject or rotate credentials into a running agent.                             |
| `GET /admin/status`     | Is the runtime configured, and as whom.                                        |

`POST /admin/configure` is the agent-side half of a credential rotation: when
`POST /registry/v1/agents/{agentId}/credential` hands an operator a new
`clientSecret`, they push it in here and the runtime swaps its clients in
place — no restart, and the agent id is unchanged either way.

<Note>
  **Swarmd derives your webhook URL from your agent card URL** — it strips
  everything from `/.well-known/` onwards and appends `/admin/webhook`. There is
  no `webhookUrl` field anywhere in the API. Mount the admin app at `/admin`
  (which `serve()` does for you) and it lines up.

  The same derivation is why **an agent shell never receives a webhook**: until
  you attach a card with `PUT /registry/v1/agents/{agentId}`
  ([Setup, step 7](/sdks/python/setup#step-7-attach-your-agent-card)) there is no
  card URL to derive from, so there is no target to deliver to. Push starts
  working the moment the card is attached.
</Note>

### Signature verification

Every delivery carries `X-Swarmd-Timestamp` and `X-Swarmd-Signature`. The
signature is `HMAC-SHA256(secret, "{timestamp}." + raw_body)`, compared in
constant time, with a ±5 minute window on the timestamp to bound replay.

`create_admin_app()` does this for you. If you're serving `/admin/webhook`
yourself, use `verify_webhook()` — and pass the **raw** body bytes, not a
re-serialised dict:

```python theme={null}
from swarmd_sdk import verify_webhook, WebhookVerificationError

try:
    verify_webhook(request.headers, raw_body, secret)
except WebhookVerificationError:
    return Response(status_code=401)
```

### What triggers a refresh

Four event types, delivered in the `X-Swarmd-Event-Type` header:
`SUBSCRIPTION_CHANGED`, `AGENT_LIFECYCLE`, `TENANT_LIFECYCLE`,
`MCP_GRANTS_CHANGED`. All four mean the same thing — *something about your
world changed, re-pull* — so the SDK maps them all to one refresh callback.
Unknown event types are acknowledged rather than rejected, so a new event type
on the platform can't dead-letter your agent.

***

## Bonus: correlation IDs

One user request can fan out across five agents and a dozen MCP calls. To make
that legible in the audit log, every hop carries the same `X-Correlation-Id`.

`CorrelationIdMiddleware` captures the inbound header into a `ContextVar`, and
every outbound client the SDK builds copies that var onto its requests. The
first hop in a chain adopts the id the relay generated; every subsequent hop
inherits it.

You get this for free from `serve()`. If you're building your own server:

```python theme={null}
from swarmd_sdk import CorrelationIdMiddleware

app.add_middleware(CorrelationIdMiddleware)
```

***

## Now go build

<CardGroup cols={3}>
  <Card title="Core SDK" icon="cube" href="/sdks/python/core-sdk">
    Use these pieces directly, in any framework.
  </Card>

  <Card title="Google ADK" icon="google" href="/sdks/python/google-adk">
    Three helpers, one running ADK agent.
  </Card>

  <Card title="LangChain" icon="link" href="/sdks/python/langchain">
    The same three helpers, for LangGraph.
  </Card>
</CardGroup>
