Levain LabsLevain Labs
Embedded Agents

Credential Hand-off

Hand each customer's credentials to their workspace — push tokens your product holds, or let the customer connect their own tools.

Each customer's credentials live in that customer's workspace. Levain stores them encrypted and ships them only into that workspace's sandboxed runs. There are two ways to get them there:

  • Push — your customer authorizes Slack, GitHub, or any other provider inside your product, against your OAuth app, and your backend pushes the resulting tokens into their workspace.
  • Hosted connect link — you mint a link through the organization API, put it in front of the customer, and they authorize the provider directly. The credential lands on the same workspace integration record.

Either way, agents pick the credentials up on their next run.

Push a credential

curl -X PUT "https://api.levainlabs.com/api/v1/org/workspaces/$WS_ID/integrations/github" \
  -H "Authorization: Bearer $LEVAIN_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "oauth2",
    "external_account_id": "acme-tools",
    "display_name": "Acme Tools GitHub",
    "credentials": [
      {
        "type": "oauth_access",
        "payload": {"access_token": "gho_..."},
        "expires_at": "2026-08-26T18:00:00Z"
      }
    ]
  }'

PUT is create-or-rotate: the same call with a fresh token replaces the old one. external_account_id is your identifier for the connected account, so one workspace can hold several accounts of the same provider.

The expiry contract

Levain never refreshes a pushed token — refresh tokens and OAuth client secrets stay with the owner of the OAuth app, which is you. That makes expiry your side of the contract:

  • oauth_access credentials must carry expires_at; the API refuses them without it.
  • Push a fresh token before that moment, on whatever schedule your token lifetimes demand.
  • If an agent run reports that a credential no longer works, the credential.revoked webhook event fires so you can re-authorize the customer promptly. Levain never probes a pushed credential itself; see MCP servers for what a run has to report.

Long-lived credentials — personal access tokens (pat), bot tokens, static headers — can be pushed without expires_at and rotated whenever you choose.

MCP servers

A headless workspace has no user to complete an MCP server's OAuth flow, so you push the server and its credential together: provider mcp, kind: mcp_url, the server's server_url, and either a static_headers credential or a bearer.

curl -X PUT "https://api.levainlabs.com/api/v1/org/workspaces/$WS_ID/integrations/mcp" \
  -H "Authorization: Bearer $LEVAIN_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "mcp_url",
    "external_account_id": "creator-42",
    "display_name": "Acme Data",
    "server_url": "https://mcp.yourproduct.com/mcp",
    "credentials": [
      {
        "type": "static_headers",
        "payload": {"Authorization": "Bearer creator-42-token"}
      }
    ]
  }'
  • server_url must be an absolute https URL. The response and the list endpoint echo it, so you can verify what is stored.
  • static_headers is a flat map of header names to string values, sent verbatim on every request to the server. Rotate it by pushing a new map.
  • For a bearer instead, push oauth_access (access_token, optional token_type; expires_at required), pat (token) or bot_token (bot_token). It is sent as Authorization: Bearer …; when both are pushed, static_headers win on a header-name collision.
  • Each push is the whole credential set: switching from static_headers to a bearer deletes the old headers.

Agents reach the server by declaring the integration's display_name in @mcp_servers, like any other integration used as a tool.

Levain never calls the MCP server itself, so it cannot see a 401 or 403 the server returns to a run. For an MCP credential, credential.revoked fires only when the recipe reports the rejection through the SDK:

from levain_sdk.integrations import find_attached, refresh_attached

integration = find_attached("Acme Data")
await refresh_attached(integration["id"], auth_status=401)

Without that report, the only signal you get is the expires_at-based health on the list endpoint.

When you don't want to run the OAuth flow yourself, mint a start link for the workspace and let the customer authorize from inside your product:

curl -X POST "https://api.levainlabs.com/api/v1/org/workspaces/$WS_ID/integrations/github/connect-link" \
  -H "Authorization: Bearer $LEVAIN_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"return_url": "https://app.yourproduct.com/settings/connections"}'

The response carries a connect_url and an expires_at. Put the URL behind a "Connect GitHub" button (or redirect to it); links are short-lived, so mint one per connect attempt rather than storing them. Opening it sends the customer straight to the provider's consent screen — no Levain account, login, or page in between. The only surfaces they see are yours and the provider's.

Treat the URL itself as a secret for its full expires_at window, not just the credential it produces: it's a bearer token with no server-side single-use tracking, so whoever opens it — not necessarily your customer — completes the OAuth flow and lands their own provider account on that workspace's integration record. Don't log the URL, put it somewhere that leaks via a referrer header, or forward it through anything untrusted.

When the customer approves, their browser returns to your return_url with an integration_connected query parameter carrying the new integration's id (or integration_error if the flow failed), and the credential lands on the same per-workspace integration record that GET /api/v1/org/workspaces/{workspace_id}/integrations lists.

One difference from pushed tokens: credentials acquired through a hosted connect flow are obtained by Levain's provider apps, so Levain holds the refresh side and keeps them fresh. The expiry contract above applies only to tokens you push yourself.

Per-end-user identity

Delegation is per workspace: one workspace is one customer, and every agent run in it uses that customer's credentials. When a customer is a company rather than a person, you can keep individual people's connections apart today using external_account_id — it is your identifier, so key it per end user (for example acct-42:alice). Each end user's connection then lands on its own integration record with its own credentials, health, and revocation.

A first-class version of this is specified and ships after the connect flow:

  • Connect-link minting and credential pushes will accept an optional end_user identifier — an opaque string that is yours, like external_account_id.
  • Integration records will carry it, so listing and revoking by person needs no naming convention.
  • Runs will be scopable to an end user, so an agent acting for one person in a customer's account uses only that person's connections.

Until then, the external_account_id convention above is the supported way to keep end users apart, and it maps cleanly onto the end_user field when it arrives.

Check health

GET /api/v1/org/workspaces/{workspace_id}/integrations lists each integration with per-credential health derived from stored expiry times. Secret material is never returned; you get expires_at and an expired flag, which is enough to drive a re-push job or a "reconnect" prompt in your product.

Revoke

When a customer disconnects an account, delete the integration:

curl -X DELETE "https://api.levainlabs.com/api/v1/org/workspaces/$WS_ID/integrations/$INTEGRATION_ID" \
  -H "Authorization: Bearer $LEVAIN_ORG_KEY"

Stored credential material is deleted immediately; the integration record is kept, marked revoked, so your audit trail stays intact.

On this page