Skip to main content
Practical recipes for the most common agent account integration patterns. For conceptual background, see Agent Accounts.

Prerequisites

  • A Runlayer instance (referred to as RUNLAYER_URL below)
  • An agent account with Client ID and Client Secret (created in Admin -> Agent Accounts)
  • For OBO recipes: at least one active delegation from a user to the agent account
  • curl and jq installed (for shell examples)
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:
MethodHeaderUsed forIdentity
Agent token (M2M / OBO)Authorization: Bearer <agent-jwt>Proxy tool calls (/proxy/...)Agent account (+ delegated user for OBO)
User JWTAuthorization: Bearer <user-jwt>Management endpoints (delegations, session grants)User
User API keyx-runlayer-api-key: <key>Management endpoints AND proxy tool callsUser (key owner)
User API keys are the easiest option for scripting and automation. Create one in Settings -> 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.
export API_KEY="rl_..."  # User API key from Settings -> API Keys
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.
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.

Get an M2M Token (Autonomous Agent)

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

Request an M2M token

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:
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"]
2

Call an MCP tool

curl -X POST "$RUNLAYER_URL/api/v1/proxy/$SERVER_ID/tools" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tool_name": "list_repos",
    "arguments": { "org": "acme-corp" }
  }'
3

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.

Get an OBO Token with a Runlayer User UUID

Use this when your system already has the Runlayer user UUID (e.g., stored when the delegation was created, or looked up via the Delegations API). This is the simplest OBO flow.
1

Obtain the Runlayer user UUID

If you don’t already have it, query the delegations list:
# List delegations for your agent account
# Using a user JWT:
curl -s "$RUNLAYER_URL/api/v1/agent-accounts/$AGENT_ACCOUNT_ID/delegations" \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq '.[] | {user_id: .delegator_user_id, email: .delegator_email}'

# Or using an API key:
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, email: .delegator_email}'
Store the delegator_user_id alongside your own user identifier for future lookups.
2

Request an OBO token

Pass the Runlayer user UUID as actor_token with the custom token type:
USER_ID="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"

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" \
  --data-urlencode "actor_token=$USER_ID" \
  --data-urlencode "actor_token_type=urn:runlayer:token-type:user-id")

OBO_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')
Python equivalent:
resp = httpx.post(
    f"{RUNLAYER_URL}/api/v1/oauth/token",
    data={
        "grant_type": "client_credentials",
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "actor_token": user_id,
        "actor_token_type": "urn:runlayer:token-type:user-id",
    },
)
resp.raise_for_status()
obo_token = resp.json()["access_token"]
The user must have an active delegation to your agent account. Without one, this returns 403 invalid_grant.
3

Call MCP tools on behalf of the user

curl -X POST "$RUNLAYER_URL/api/v1/proxy/$SERVER_ID/tools" \
  -H "Authorization: Bearer $OBO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tool_name": "list_repos",
    "arguments": { "org": "acme-corp" }
  }'
The call enforces the intersection of agent account policies and the user’s policies.

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. Combine credential validation and OBO exchange in one call:
USER_ACCESS_TOKEN="eyJhbGciOiJSUz..."  # WorkOS user JWT

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" \
  --data-urlencode "actor_token=$USER_ACCESS_TOKEN" \
  --data-urlencode "actor_token_type=urn:ietf:params:oauth:token-type:access_token")

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

Two-step flow (RFC 8693)

If you already have a Runlayer agent JWT (from a previous client_credentials call), exchange it for an OBO token:
# Step 1: You already have AGENT_TOKEN from a client_credentials grant

# Step 2: Exchange for OBO
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 "subject_token=$AGENT_TOKEN" \
  --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  --data-urlencode "actor_token=$USER_ACCESS_TOKEN" \
  --data-urlencode "actor_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:
ApproachWhen to useExtra API calls
Store UUID at delegation timeYou control the delegation creation flowNone at token time
Query the Delegations APIYou need to look up users dynamicallyOne per lookup
Use a WorkOS access tokenUsers authenticate via WorkOS/AuthKitNone (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

Fetch the delegation list and match by email:
# 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()

user_id = next(
    d["delegator_user_id"]
    for d in delegations
    if d["delegator_email"] == target_email
)

Option C: Use a WorkOS access token directly

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

OBO flow sequence

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.
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.

Create a personal session grant

The grantor must have an active OAuth session for the server (they must have connected to it first).
# With user JWT:
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 .

# Or with API key:
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 .
Response includes the grant id, grantor_user_id, is_active, and timestamps.

List session grants

# 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.
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 .
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.

Credential resolution order

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

Revoke a session grant

curl -s -X DELETE "$RUNLAYER_URL/api/v1/session-grants/$GRANT_ID" \
  -H "x-runlayer-api-key: $API_KEY" | jq .
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.

Example: Slack agent with shared fallback

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

Alice creates a personal grant

Alice has authorized Slack in Runlayer. She creates a personal session grant (using her JWT or API key):
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 }'
2

Alice promotes her grant to shared

Only the grantor can toggle their own grant. Alice promotes it so users without their own Slack credentials can still use the bot:
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'" }'
3

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

Complete Python Example

End-to-end script that authenticates, resolves a user, manages session grants, and calls an MCP tool.
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."""
    resp = httpx.post(
        f"{RUNLAYER_URL}/api/v1/oauth/token",
        data={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
            "actor_token": user_id,
            "actor_token_type": "urn:runlayer:token-type:user-id",
        },
    )
    if resp.status_code == 403:
        error = resp.json()
        raise RuntimeError(
            f"OBO token failed: {error.get('detail', 'unknown error')}. "
            "Check that a delegation exists for this user."
        )
    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."""
    resp = httpx.post(
        f"{RUNLAYER_URL}/api/v1/proxy/{server_id}/tools",
        headers={"Authorization": f"Bearer {token}"},
        json={"tool_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

ErrorCauseFix
401 invalid_clientWrong client ID or secretVerify credentials; check if they were rotated
403 invalid_grant: No valid delegationNo active delegation for this userUser must delegate to the agent in the Runlayer UI
400 invalid_grant: actor_token must be a valid UUIDMalformed user UUID in actor_tokenPass a valid UUID string
403 Delegator user is deactivatedThe delegating user’s account is disabledContact admin to reactivate the user
401 No credentials available for this serverNo session grant for this agent + serverCreate a session grant (user must authorize the OAuth server first)
401 OAuth session not foundGrantor’s OAuth session was deletedGrantor must re-authorize the MCP server
401 OAuth session expiredGrantor’s OAuth tokens expiredGrantor must re-authorize the MCP server
403 Policy deniedPBAC policy blocked the tool callReview agent account and user policies in Admin