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_accesscredentials must carryexpires_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.revokedwebhook 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_urlmust be an absolute https URL. The response and the list endpoint echo it, so you can verify what is stored.static_headersis 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, optionaltoken_type;expires_atrequired),pat(token) orbot_token(bot_token). It is sent asAuthorization: Bearer …; when both are pushed,static_headerswin on a header-name collision. - Each push is the whole credential set: switching from
static_headersto 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.
Hosted connect link
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_useridentifier — an opaque string that is yours, likeexternal_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.