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

# Agent Account Authentication Recipes

> Copy-paste recipes for authenticating agent accounts, mapping external users, and managing session grants for OAuth-protected MCP servers.

Practical recipes for the most common agent account integration patterns. For conceptual background, see [Agent Accounts](/platform-agent-accounts).

## Prerequisites

* A Runlayer instance (referred to as `RUNLAYER_URL` below)
* An agent account with **Client ID** and **Client Secret** (created in **Settings → Agent Accounts**)
* For OBO recipes: at least one active **delegation** from a user to the agent account
* `curl` and `jq` installed (for shell examples)

```bash theme={null}
export RUNLAYER_URL="https://your-runlayer-instance.com"
export CLIENT_ID="your-client-id"
export CLIENT_SECRET="your-client-secret"
```

## Authentication Methods

This cookbook uses three different authentication methods depending on the endpoint:

| Method                      | Header                              | Used for                                           | Identity                                 |
| --------------------------- | ----------------------------------- | -------------------------------------------------- | ---------------------------------------- |
| **Agent token** (M2M / OBO) | `Authorization: Bearer <agent-jwt>` | Proxy tool calls (`/proxy/...`)                    | Agent account (+ delegated user for OBO) |
| **User JWT**                | `Authorization: Bearer <user-jwt>`  | Management endpoints (delegations, session grants) | User                                     |
| **User API key**            | `x-runlayer-api-key: <key>`         | Management endpoints AND proxy tool calls          | User (key owner)                         |

**User API keys** are the easiest option for scripting and automation. Create one in **Settings → Personal API keys** in the Runlayer UI. They work anywhere a user JWT works -- delegations, session grants, and proxy calls -- but they always carry **user** identity, not agent identity.

```bash theme={null}
export API_KEY="rl_..."  # User API key from Settings → Personal API keys
```

<Note>
  API keys **cannot** be used for agent account authentication (`/oauth/token` with `client_credentials`). That endpoint requires the agent's Client ID and Client Secret. Use API keys for management endpoints and for proxy calls where user-only identity is sufficient.
</Note>

<Tip>
  **When to use which:** Use **agent tokens** (M2M/OBO) for proxy tool calls when you need agent identity, policy intersection, and audit trails tied to the agent. Use **API keys** for automation scripts that manage delegations and session grants, or for simple proxy calls where user-only identity is enough.
</Tip>

<Warning>
  **Migrating from the `client_credentials` OBO shortcut:** earlier versions
  documented passing `subject_token` / `subject_token_type` directly on the
  `client_credentials` grant. That shape is still accepted for backward
  compatibility but deprecated — switch to the two-step flow: mint an agent
  token with `client_credentials`, then exchange it with
  `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` (agent token in
  `actor_token`, user identity in `subject_token`, per RFC 8693 §2.1).
</Warning>

## End-to-End OBO Flow

The full On-Behalf-Of lifecycle, from one-time user consent through a proxied tool call. Each numbered step maps to a recipe below.

```mermaid theme={null}
sequenceDiagram
    autonumber
    actor User as User (delegator)
    participant App as Your Application
    participant RL as Runlayer
    participant MCP as MCP Server (upstream)

    Note over User,RL: One-time setup (Runlayer UI)
    User->>RL: Connect to the agent account
    Note over User,RL: Creates the delegation, plus session grants<br/>for OAuth-protected servers

    Note over App,RL: Step 1 — mint an agent token
    App->>RL: POST /api/v1/oauth/token<br/>grant_type=client_credentials<br/>client_id + client_secret
    RL-->>RL: Validate client credentials
    RL-->>App: Agent JWT (1h — cache and reuse across users)

    Note over App,RL: Step 2 — exchange for an OBO token (RFC 8693)
    App->>RL: POST /api/v1/oauth/token<br/>grant_type=token-exchange<br/>actor_token=agent JWT<br/>subject_token=user UUID / email / WorkOS JWT
    RL-->>RL: Verify agent JWT (actor)
    RL-->>RL: Resolve user from subject_token
    RL-->>RL: Check active delegation (user to agent)
    alt Denied: no delegation, user not found or inactive
        RL-->>App: 400 invalid_grant<br/>+ X-Runlayer-Connect-URL header
        App->>User: Forward the connect URL
        User->>RL: Sign in and click Connect<br/>(creates delegation + session grants)
        App->>RL: Retry token exchange
    end
    RL-->>App: OBO JWT (sub=user, act.sub=agent, 1h)

    Note over App,MCP: Step 3 — call MCP tools on behalf of the user
    App->>RL: POST /api/v1/proxy/{server_id}/mcp<br/>Authorization: Bearer OBO JWT
    RL-->>RL: Verify OBO JWT
    RL-->>RL: Enforce policy intersection<br/>(agent + user + server policies)
    RL-->>RL: Resolve OAuth credentials via session grant<br/>(personal grant, else shared grant, else 401)
    RL->>MCP: Forward tool call (grantor's credentials)
    MCP-->>RL: Tool result
    RL-->>App: Tool result (audit-logged with agent + user identity)
```

## Get an M2M Token (Autonomous Agent)

Use this when your agent operates independently, without a specific user context.

<Steps>
  <Step title="Request an M2M token">
    ```bash theme={null}
    TOKEN_RESPONSE=$(curl -s -X POST "$RUNLAYER_URL/api/v1/oauth/token" \
      -H "Content-Type: application/x-www-form-urlencoded" \
      --data-urlencode "grant_type=client_credentials" \
      --data-urlencode "client_id=$CLIENT_ID" \
      --data-urlencode "client_secret=$CLIENT_SECRET")

    ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
    ```

    Python equivalent:

    ```python theme={null}
    import httpx

    resp = httpx.post(
        f"{RUNLAYER_URL}/api/v1/oauth/token",
        data={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
    )
    resp.raise_for_status()
    access_token = resp.json()["access_token"]
    ```
  </Step>

  <Step title="Call an MCP tool">
    ```bash theme={null}
    curl -X POST "$RUNLAYER_URL/api/v1/proxy/$SERVER_ID/mcp" \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
          "name": "list_repos",
          "arguments": { "org": "acme-corp" }
        }
      }'
    ```
  </Step>

  <Step title="Handle token expiry">
    M2M tokens are valid for **1 hour**. Re-request a token before it expires. A simple approach is to request a fresh token before each batch of calls, or cache the token and refresh when you receive a `401`.
  </Step>
</Steps>

## Get an OBO Token with a Runlayer User UUID

Use this when your system already has the Runlayer user UUID (for example, stored when the delegation was created). This is the simplest OBO flow.

<Steps>
  <Step title="Obtain the Runlayer user UUID">
    If you do not already have the UUID, list delegations and store each `delegator_user_id` in your own user table when you can identify which app user created that delegation. The list is useful for populating or refreshing your mapping, but it is not an email directory.

    <CodeGroup>
      ```bash User JWT theme={null}
      curl -s "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ACCOUNT_ID/delegations" \
        -H "Authorization: Bearer $ADMIN_TOKEN" | jq '.[] | {user_id: .delegator_user_id, is_active: .is_active}'
      ```

      ```bash API key theme={null}
      curl -s "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ACCOUNT_ID/delegations" \
        -H "x-runlayer-api-key: $API_KEY" | jq '.[] | {user_id: .delegator_user_id, is_active: .is_active}'
      ```
    </CodeGroup>

    Store the `delegator_user_id` alongside your own user identifier for future lookups.

    <Note>
      The delegations API returns `delegator_user_id` (a UUID) but does not include the user's email. If your starting point is an email address, either use the email-based OBO flow below or store `email -> delegator_user_id` in your own database when the delegation is created.
    </Note>
  </Step>

  <Step title="Request an OBO token">
    Getting an OBO token is a two-step flow: mint an agent token with `client_credentials` (the same call as M2M above), then exchange it for an OBO token via RFC 8693 token exchange. The agent token is cacheable and reused across many user exchanges, and your `client_secret` never travels on the exchange call.

    ```bash theme={null}
    USER_ID="user-uuid-from-delegation"

    # Step 1: mint agent token (same call as M2M)
    AGENT_RESPONSE=$(curl -s -X POST "$RUNLAYER_URL/api/v1/oauth/token" \
      -H "Content-Type: application/x-www-form-urlencoded" \
      --data-urlencode "grant_type=client_credentials" \
      --data-urlencode "client_id=$CLIENT_ID" \
      --data-urlencode "client_secret=$CLIENT_SECRET")
    AGENT_TOKEN=$(echo "$AGENT_RESPONSE" | jq -r '.access_token')

    # Step 2: exchange for OBO token (RFC 8693)
    TOKEN_RESPONSE=$(curl -s -X POST "$RUNLAYER_URL/api/v1/oauth/token" \
      -H "Content-Type: application/x-www-form-urlencoded" \
      --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
      --data-urlencode "actor_token=$AGENT_TOKEN" \
      --data-urlencode "actor_token_type=urn:ietf:params:oauth:token-type:access_token" \
      --data-urlencode "subject_token=$USER_ID" \
      --data-urlencode "subject_token_type=urn:runlayer:token-type:user-id")

    OBO_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
    ```

    Python equivalent:

    ```python theme={null}
    TOKEN_URL = f"{RUNLAYER_URL}/api/v1/oauth/token"
    TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"
    ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"


    def get_agent_token() -> str:
        resp = httpx.post(
            TOKEN_URL,
            data={
                "grant_type": "client_credentials",
                "client_id": CLIENT_ID,
                "client_secret": CLIENT_SECRET,
            },
        )
        resp.raise_for_status()
        return resp.json()["access_token"]


    def exchange_for_user(
        agent_token: str,
        subject_token: str,
        subject_token_type: str,
        resource: list[str] | None = None,
    ) -> str:
        data = {
            "grant_type": TOKEN_EXCHANGE_GRANT,
            "actor_token": agent_token,
            "actor_token_type": ACCESS_TOKEN_TYPE,
            "subject_token": subject_token,
            "subject_token_type": subject_token_type,
        }
        # Optional: scope the token's `aud` to specific MCP servers (RFC 8707).
        # httpx encodes a list value as repeated `resource=` fields.
        if resource:
            data["resource"] = resource
        resp = httpx.post(TOKEN_URL, data=data)
        resp.raise_for_status()
        return resp.json()["access_token"]


    agent_token = get_agent_token()
    obo_token = exchange_for_user(agent_token, user_id, "urn:runlayer:token-type:user-id")
    ```

    <Note>
      Pass `resource=[f"{RUNLAYER_URL}/api/v1/proxy/<server-id>/mcp"]` to scope
      the minted token to specific servers. The token's `aud` is narrowed to the
      servers the principal can access (dual-form: resource URL +
      `runlayer:server:<id>` URN). This is currently observe-only — recorded but
      not yet enforced at the proxy.
    </Note>

    <Warning>
      The user must have an **active delegation** to your agent account. Without one, this returns `400 invalid_grant: subject token exchange denied`.
    </Warning>
  </Step>

  <Step title="Call MCP tools on behalf of the user">
    ```bash theme={null}
    curl -X POST "$RUNLAYER_URL/api/v1/proxy/$SERVER_ID/mcp" \
      -H "Authorization: Bearer $OBO_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
          "name": "list_repos",
          "arguments": { "org": "acme-corp" }
        }
      }'
    ```

    The call enforces the **intersection** of agent account policies and the user's policies.
  </Step>
</Steps>

## Get an OBO Token with a User Email

Use this when your system tracks users by email rather than UUID. Runlayer looks up the active user whose `email` matches exactly (case-sensitive) and issues an OBO token scoped to them.

```bash theme={null}
USER_EMAIL="alice@example.com"

# Step 1: mint agent token (same call as M2M)
AGENT_RESPONSE=$(curl -s -X POST "$RUNLAYER_URL/api/v1/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET")
AGENT_TOKEN=$(echo "$AGENT_RESPONSE" | jq -r '.access_token')

# Step 2: exchange for OBO token (RFC 8693)
TOKEN_RESPONSE=$(curl -s -X POST "$RUNLAYER_URL/api/v1/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  --data-urlencode "actor_token=$AGENT_TOKEN" \
  --data-urlencode "actor_token_type=urn:ietf:params:oauth:token-type:access_token" \
  --data-urlencode "subject_token=$USER_EMAIL" \
  --data-urlencode "subject_token_type=urn:runlayer:token-type:user-email")

OBO_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
```

Reusing the helpers from the UUID recipe above:

```python theme={null}
agent_token = get_agent_token()
obo_token = exchange_for_user(agent_token, user_email, "urn:runlayer:token-type:user-email")
```

<Warning>
  The user must have an **active delegation** to your agent account. The email is matched exactly as stored in Runlayer (no case normalization). Prefer user UUIDs for long-lived integrations -- emails can change if a user is renamed.
</Warning>

## Get an OBO Token with a WorkOS Access Token

Use this when the end user authenticates through WorkOS/AuthKit and your app holds their access token. No user UUID mapping needed -- Runlayer resolves the user server-side.

Getting an OBO token is a two-step flow: mint an agent token with `client_credentials`, then exchange it for an OBO token via RFC 8693 token exchange. Per RFC 8693 §2.1 the user identity goes in `subject_token` and the agent JWT goes in `actor_token`. As with the other recipes, the agent token is cacheable and reused across many user exchanges, and your `client_secret` never travels on the exchange call:

```bash theme={null}
USER_ACCESS_TOKEN="eyJhbGci..."  # WorkOS user JWT

# Step 1: mint agent token (same call as M2M)
AGENT_RESPONSE=$(curl -s -X POST "$RUNLAYER_URL/api/v1/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET")
AGENT_TOKEN=$(echo "$AGENT_RESPONSE" | jq -r '.access_token')

# Step 2: exchange for OBO token (RFC 8693)
TOKEN_RESPONSE=$(curl -s -X POST "$RUNLAYER_URL/api/v1/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  --data-urlencode "actor_token=$AGENT_TOKEN" \
  --data-urlencode "actor_token_type=urn:ietf:params:oauth:token-type:access_token" \
  --data-urlencode "subject_token=$USER_ACCESS_TOKEN" \
  --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token")

OBO_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
```

## Mapping External User IDs to Runlayer Users

Most applications maintain their own user identity. To issue OBO tokens, you need to map your user IDs to Runlayer user UUIDs. Choose the approach that fits your architecture:

| Approach                          | When to use                                                              | Extra API calls                      |
| --------------------------------- | ------------------------------------------------------------------------ | ------------------------------------ |
| **Store UUID at delegation time** | You control the delegation creation flow                                 | None at token time                   |
| **Query the Delegations API**     | You already mapped a user to a UUID and want to verify active delegation | One per check                        |
| **Pass the user email**           | Your system already tracks users by email                                | None (Runlayer looks up by email)    |
| **Use a WorkOS access token**     | Users authenticate via WorkOS/AuthKit                                    | None (Runlayer resolves server-side) |

### Option A: Store UUID at delegation time

When a user delegates to your agent in the Runlayer UI, persist their `delegator_user_id` in your database alongside your own user record. At OBO token time, look up the stored UUID -- no extra API calls needed.

### Option B: Query the Delegations API

Use this when your app has already stored a Runlayer user UUID and you want to confirm that UUID still has an active delegation. The delegations list can also refresh your local mapping, but it cannot tell you which UUID belongs to `alice@example.com` because the response does not include email addresses.

```python theme={null}
# Authenticate with either a user JWT or an API key
headers = {"x-runlayer-api-key": api_key}
# Or: headers = {"Authorization": f"Bearer {user_jwt}"}

resp = httpx.get(
    f"{RUNLAYER_URL}/api/v1/agent-accounts/{agent_account_id}/delegations",
    headers=headers,
)
delegations = resp.json()

# Your app owns this lookup table.
runlayer_user_id = lookup_runlayer_user_id(external_user_id)

active_user_ids = {
    d["delegator_user_id"]
    for d in delegations
    if d["is_active"]
}

if runlayer_user_id not in active_user_ids:
    raise ValueError("User has not delegated to this agent account")
```

<Note>
  The delegations response includes `delegator_user_id`, `is_active`, and timing fields such as `starts_at`, `expires_at`, and `revoked_at`. It does not include user email addresses. If your app starts from email, use Option C or keep your own `email -> delegator_user_id` mapping.
</Note>

### Option C: Pass the user email

Skip UUID mapping: pass the user's email as `subject_token` with `subject_token_type=urn:runlayer:token-type:user-email`. Runlayer matches the email exactly (case-sensitive) against the `User.email` record and resolves the user server-side. Prefer UUIDs for long-lived integrations -- emails can change.

### Option D: Use a WorkOS access token directly

Skip the mapping entirely. If your users authenticate through WorkOS/AuthKit, pass their JWT as `subject_token` with `subject_token_type=urn:ietf:params:oauth:token-type:access_token`. Runlayer resolves the user server-side from the JWT claims.

Whichever option you choose, the token exchange itself is the same — see the [End-to-End OBO Flow](#end-to-end-obo-flow) diagram above.

## Session Grants for OAuth-Protected Servers

When an agent makes OBO calls to an OAuth-protected MCP server (e.g., GitHub, Slack, Google Workspace), it needs **session grants** -- a user's OAuth credentials shared with the agent for that specific server.

Without a session grant, proxy calls return `401: No credentials available for this server`.

<Note>
  Session grants are independent from delegations. A delegation controls **who** the agent can act as. A session grant controls **whose OAuth credentials** are used for a specific server.
</Note>

### Create a personal session grant

The grantor must have an active OAuth session for the server (they must have connected to it first).

<CodeGroup>
  ```bash User JWT theme={null}
  curl -s -X POST "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ACCOUNT_ID/session-grants" \
    -H "Authorization: Bearer $USER_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{
      \"server_id\": \"$SERVER_ID\",
      \"shared\": false
    }" | jq .
  ```

  ```bash API key theme={null}
  curl -s -X POST "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ACCOUNT_ID/session-grants" \
    -H "x-runlayer-api-key: $API_KEY" \
    -H "Content-Type: application/json" \
    -d "{
      \"server_id\": \"$SERVER_ID\",
      \"shared\": false
    }" | jq .
  ```
</CodeGroup>

Response includes the grant `id`, `grantor_user_id`, `is_active`, and timestamps.

### List session grants

```bash theme={null}
# Returns your own grants + shared grants (other users' personal grants are not visible)
curl -s "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ACCOUNT_ID/session-grants" \
  -H "x-runlayer-api-key: $API_KEY" | jq .

# Filter by server
curl -s "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ACCOUNT_ID/session-grants?server_id=$SERVER_ID" \
  -H "x-runlayer-api-key: $API_KEY" | jq .
```

### Toggle shared / personal

This endpoint **flips** the current state of your grant: personal becomes shared, shared becomes personal. It does not set an absolute value -- the `shared` field in the request body is ignored. It only operates on **your own** grant, and requires agent-account **manage** access (shared grants are an admin-level concern).

```bash theme={null}
curl -s -X POST "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ACCOUNT_ID/session-grants/toggle" \
  -H "x-runlayer-api-key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"server_id\": \"$SERVER_ID\"
  }" | jq .
```

<Warning>
  This call is **not idempotent**. Calling it twice flips the grant back to its original state. Check the `shared` field in the response to confirm the result. When toggling personal to shared, all other grantors' grants for that (agent, server) pair are revoked -- only one shared grant can exist.
</Warning>

### Credential resolution order

When an agent makes an OBO call to an OAuth-protected server, Runlayer resolves credentials in this order:

```mermaid theme={null}
flowchart TD
    A["OBO call to OAuth server"] --> B{"Caller has personal<br/>session grant?"}
    B -->|Yes| C["Use caller's OAuth credentials"]
    B -->|No| D{"Shared grant exists<br/>for this server?"}
    D -->|Yes| E["Use shared grantor's<br/>OAuth credentials"]
    D -->|No| F["401: No credentials available"]
```

### Revoke a session grant

```bash theme={null}
curl -s -X DELETE "$RUNLAYER_URL/api/v1/session-grants/$GRANT_ID" \
  -H "x-runlayer-api-key: $API_KEY" | jq .
```

<Note>
  Revoking a session grant does **not** revoke the user's delegation. The agent can still issue OBO tokens for that user, but calls to the affected OAuth server will fail until a new session grant is created.
</Note>

### Example: Slack agent with shared fallback

A "Support Bot" agent account needs to call a Slack MCP server on behalf of multiple users.

<Steps>
  <Step title="Alice creates a personal grant">
    Alice has authorized Slack in Runlayer. She creates a personal session grant (using her JWT or API key):

    ```bash theme={null}
    curl -s -X POST "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ID/session-grants" \
      -H "x-runlayer-api-key: $ALICE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "server_id": "'$SLACK_SERVER_ID'", "shared": false }'
    ```
  </Step>

  <Step title="Alice promotes her grant to shared">
    Only the grantor can toggle their own grant, and toggling requires agent-account manage access. Alice (an admin) promotes it so users without their own Slack credentials can still use the bot:

    ```bash theme={null}
    curl -s -X POST "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ID/session-grants/toggle" \
      -H "x-runlayer-api-key: $ALICE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "server_id": "'$SLACK_SERVER_ID'" }'
    ```
  </Step>

  <Step title="OBO calls resolve credentials automatically">
    * **Support Bot as Alice** -> uses Alice's own OAuth credentials (her grant is matched first because she is both grantor and caller)
    * **Support Bot as Bob** (Bob has no grant) -> falls back to Alice's shared grant
    * **Support Bot as Carol** (Carol creates her own personal grant later) -> uses Carol's own credentials
  </Step>
</Steps>

## Complete Python Example

End-to-end script that authenticates, resolves a user, manages session grants, and calls an MCP tool.

```python theme={null}
import httpx

RUNLAYER_URL = "https://your-runlayer-instance.com"
CLIENT_ID = "your-client-id"
CLIENT_SECRET = "your-client-secret"
SERVER_ID = "your-server-id"


def get_obo_token(user_id: str) -> str:
    """Get an OBO token for a specific user via the two-step token exchange."""
    # Step 1: mint agent token (same call as M2M)
    agent_resp = httpx.post(
        f"{RUNLAYER_URL}/api/v1/oauth/token",
        data={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        },
    )
    agent_resp.raise_for_status()
    agent_token = agent_resp.json()["access_token"]

    # Step 2: exchange for OBO token (RFC 8693)
    resp = httpx.post(
        f"{RUNLAYER_URL}/api/v1/oauth/token",
        data={
            "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
            "actor_token": agent_token,
            "actor_token_type": "urn:ietf:params:oauth:token-type:access_token",
            "subject_token": user_id,
            "subject_token_type": "urn:runlayer:token-type:user-id",
        },
    )
    if resp.status_code == 400 and resp.json().get("error") == "invalid_grant":
        msg = "OBO token denied: user not found, inactive, or no active delegation."
        connect_url = resp.headers.get("X-Runlayer-Connect-URL")
        if connect_url:
            msg += f" Send the user to: {connect_url}"
        raise RuntimeError(msg)
    resp.raise_for_status()
    return resp.json()["access_token"]


def call_tool(token: str, server_id: str, tool_name: str, arguments: dict) -> dict:
    """Call an MCP tool through the Runlayer proxy using the MCP JSON-RPC protocol."""
    resp = httpx.post(
        f"{RUNLAYER_URL}/api/v1/proxy/{server_id}/mcp",
        headers={"Authorization": f"Bearer {token}"},
        json={
            "jsonrpc": "2.0",
            "id": 1,
            "method": "tools/call",
            "params": {"name": tool_name, "arguments": arguments},
        },
    )
    if resp.status_code == 401:
        error = resp.json()
        detail = error.get("detail", "")
        if "session grant" in detail.lower() or "credentials" in detail.lower():
            raise RuntimeError(
                f"Missing session grant for server {server_id}. "
                "The user (or a shared grantor) must authorize this OAuth server first."
            )
    resp.raise_for_status()
    return resp.json()


def create_session_grant(
    api_key: str, agent_account_id: str, server_id: str, shared: bool = False
) -> dict:
    """Create a session grant for an OAuth-protected server.

    Uses an API key for authentication. A user JWT in the Authorization
    header works as well.
    """
    resp = httpx.post(
        f"{RUNLAYER_URL}/api/v1/agent-accounts/{agent_account_id}/session-grants",
        headers={"x-runlayer-api-key": api_key},
        json={"server_id": server_id, "shared": shared},
    )
    resp.raise_for_status()
    return resp.json()


# Usage
user_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
obo_token = get_obo_token(user_id)
result = call_tool(obo_token, SERVER_ID, "list_repos", {"org": "acme-corp"})
print(result)
```

## Troubleshooting

| Error                                                                                                                 | Cause                                                                                                                     | Fix                                                                                                                                            |
| --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `401 invalid_client`                                                                                                  | Wrong client ID or secret                                                                                                 | Verify credentials; check if they were rotated                                                                                                 |
| `400 invalid_grant: subject token exchange denied`                                                                    | User not found, inactive, no active delegation, or malformed `subject_token` (the denial message is deliberately uniform) | Forward the `X-Runlayer-Connect-URL` response header to the user so they can grant access in one click; double-check the `subject_token` value |
| `400 invalid_request: agent JWT must be in actor_token (...); user identity must be in subject_token (RFC 8693 §2.1)` | Two-step `token-exchange` called with the legacy inverted shape                                                           | Move the agent JWT to `actor_token` and the user identity to `subject_token`                                                                   |
| `401 No credentials available for this server. A session grant is required.`                                          | No session grant for this agent + server                                                                                  | Create a session grant (user must authorize the OAuth server first)                                                                            |
| `401 OAuth authorization required. Please authorize this server first.`                                               | Grantor's OAuth session is missing or expired                                                                             | Grantor must re-authorize the MCP server in the Runlayer UI                                                                                    |
| `403 Access denied ...` (or a tool error `Access denied by policy: ...`)                                              | PBAC policy blocked the request or tool call                                                                              | Review agent account and user policies in Settings → Policies                                                                                  |
