Skip to content

Concepts

Credentials

Stored credentials, bindings to principals, and the grants those bindings produce.

A credential is stored once in the vault; bindings determine who can use it; each binding is a grant. The same primitive backs OAuth tokens an end user authorized, managed secrets an operator provisioned, and delegations a user extended to an agent.

A grant is identified by a UUID (grant_id), refers to (but does not contain) the encrypted credential, and is revocable independently of the underlying credential. Every credential-use call passes through a grant — Alter resolves it and decrypts the credential, then either the SDK or Alter’s proxy injects the credential on the outgoing request.

LayerWhat it holdsExample
CredentialThe encrypted material — an OAuth token pair, an API key, a Basic-auth pairA Slack refresh+access token; a Stripe sk_live_…
Binding (grant)An authorization tying that credential to a principal (user / group / system / agent), with a grant_id”This Stripe key is bound to Alice” — one binding, one grant_id
SDK callapp.request() resolves the binding from JWT or explicit grant_id; Alter decrypts the credential and the SDK injects itawait app.request("GET", url, provider="stripe")

The credential type (OAuth vs managed secret) and the principal type (user / group / system / agent) are independent axes. A user-principal grant can be backed by either an OAuth credential or a managed-secret credential; the call site is the same either way.

CredentialCan back which principals
OAuthUser, System, or Agent
Managed secretUser, Group, System, or Agent

An OAuth grant is a user grant when a person consented, a system grant when the application connected an account with no end user attached, and an agent grant when a user’s grant was delegated onward to a named agent (an agent-typed OAuth grant is always a delegation child of a parent grant, never created directly). Group is the one combination OAuth does not support — group principals are managed-secret only.

A user-principal grant backed by a managed-secret credential resolves under JWT identity the same way an OAuth grant does — the principal type drives resolution, not the credential type.

A grant is owned by a principal — the who the grant belongs to. Four kinds:

PrincipalWhat it representsCreated by
UserAn end user authenticated via the app’s identity providerOAuth flow, or operator binds a managed-secret credential to one user
GroupA set of users defined in the identity providerOperator binds a managed-secret credential to an IDP group
SystemThe app itself — no human or agentOperator provisions a managed-secret credential with no principal binding
AgentA named, operator-provisioned workload identityOperator binds a managed-secret credential to an agent

Principal kind decides how the SDK resolves the grant at call time. See Identity resolution.

  1. Active — the grant is created with status = active; successful credential use updates last_used_at.
  2. Expired — a grant-level lifetime (TTL) has elapsed. OAuth token expiry is different: Alter refreshes the token while the grant remains active.
  3. Revoked — an end user or operator explicitly revokes the grant, or revocation cascades from an ancestor grant or connection.

A revoked grant raises GrantRevokedError on the next call.

Provider scope mismatch is also separate from lifecycle status: it blocks access with a reauthorization signal until consent is repaired, without relabeling the grant as revoked.

An OAuth grant is the result of an end user authorizing the app to call a provider on their behalf. Alter stores the resulting access and refresh tokens encrypted in the vault and returns a grant_id. Subsequent calls reach the provider via app.request(); the SDK looks up the grant, refreshes the token if it has expired, and injects the credential.

Three triggers, all producing the same grant model and a grant_id on completion:

TriggerBest forHow it’s reached
Embedded widgetSPAs and web appsAlter Connect — a popup or redirect launched from a button click
Server-side redirectServer-rendered apps, email linksapp.create_connect_session() returns a connect_url; the backend redirects the user there
HeadlessCLIs, scripts, one-time setupapp.connect() opens a local browser and polls until complete

All three end at the same OAuth flow, create the same kind of grant, and produce the same revocation surface in the Wallet.

A single end user can hold many OAuth grants for the same provider — personal Gmail and work Gmail, two GitHub orgs, three Stripe accounts. Each is a separate grant with its own grant_id and account_identifier.

When the SDK resolves a grant via JWT identity and finds more than one match, it raises AmbiguousGrantError whose candidates carry each matching grant’s id, label, and account so application code can prompt the user to pick (or retry with account= / label=).

OAuth access tokens expire. Alter refreshes them transparently:

  • On each app.request(), the backend checks token age before injecting.
  • If the access token has expired (or is within a refresh buffer), the backend uses the stored refresh token to get a new one.
  • Concurrent requests for the same grant hold a single distributed lock so only one refresh runs at a time; the others wait for the result.

If the refresh fails permanently (the user revoked the app at the provider, the refresh token was invalidated), Alter raises CredentialRevokedError and the user must re-authorize.

Every OAuth grant an app holds is visible to the end user in the Wallet dashboard. The user can revoke, see when the grant was last used, and review the activity reason for each call. Revocation is immediate; the next app.request() against a revoked grant raises GrantRevokedError.

Managed secrets are credentials that the operator stores in Alter instead of an environment variable or external secret manager. Same vault, same app.request() surface, no end-user OAuth flow.

Where an OAuth grant is a credential the end user authorizes for themselves, a managed-secret credential is one the operator already holds — a Stripe API key, a Datadog token, an AWS access key. The operator configures a provider for it on the app; Alter stores the key encrypted on that provider, binds it to a principal, and returns a grant_id that the SDK uses the same way it uses any other grant.

  • The credential already exists (generated at the provider’s console).
  • The credential is per-service or per-tenant, not per-end-user.
  • Centralized credential management is preferable to scattering API keys across environments.
  • The provider issues API keys and each end user should use their own key: see End users’ own keys.

For credentials that end users authorize on their own behalf (Gmail, Slack, GitHub), use OAuth grants instead.

A provider is one configured instance on an app, the entry an operator adds under Managed Secrets: a display name, a template, the allowed API hosts and a delegation policy. Every provider is addressed by its slug. The slug is derived from the display name (Stripe Production becomes stripe-production) and is unique per app among active providers; when it is already taken, -2, -3, … is appended. Several providers can share a template or a display name, and the slug tells them apart. The template (stripe, github, Custom, …) picks the injection format, the host rules and the setup copy, and audit events are filtered by its id; it does not address a provider, except through the deprecated template_slug Connect option.

ValueAddressesUsed by
Slug (stripe-production)One provider: the runtime addressprovider= on app.request() / proxy_request(), managed-secret Connect sessions, the provider_id filter of list_grants(), an agent’s managed_secret_slugs allowlist, alter grants list --provider (the slug only), and the CLI arguments that name a provider: the <provider> argument of the alter managed-secrets commands (show, rotate, delete, credentials …, grants …), --secret on alter policy commands, and secret:<provider> references and bindings.json targets in policy files; also the --confirm value of alter managed-secrets delete
Provider id (a UUID)One providerAccepted by the same CLI arguments as the slug, except alter grants list --provider (slug only) and the --confirm value of alter managed-secrets delete (always the slug); the dashboard API addresses a provider by it, and create_managed_secret_grant() takes it as managed_secret_id
Template id (stripe)No provider, except through the deprecated template_slug Connect option, which uses the app’s one active provider of that templateChoosing what a new provider injects and where it may call; filtering audit events (alter audit --provider takes the template id, and a custom provider’s slug)

The provider’s page in the dashboard shows its Slug and its Provider ID, each with a copy button. The CLI reads a UUID-shaped value as a provider id and any other value as a slug, matched exactly (case-sensitive) among the app’s active providers. Resolving a slug reads the app’s providers, so a token also needs dashboard_secrets:read for that form. Alter never generates a UUID-shaped slug; when a provider created earlier holds one, the CLI refuses that value as ambiguous and names the provider’s id (a token that cannot read the providers passes it as an id).

Credentials live on a provider (below). Retrieval always goes through a grant: by grant_id, or by the provider’s slug, which resolves one grant. An application key passes the user’s token (provider= + user_token=); an agent resolves among the grants it holds and passes a user’s token only to choose between users. Several matching grants return HTTP 409 ambiguous_grant and raise AmbiguousGrantError; narrow the call with label, account or grant_id.

A provider holds one or more credentials — each a stored key with its own label (Default, Production, Staging, …) — and every grant serves exactly one of them:

provider (addressed by slug)
└── credential "Production" ── grant (system)
│ ── grant (group: support)
└── credential "Staging" ── grant (agent: test-runner)
  • Adding a credential stores another key under the same provider. It issues no grant on its own; bind it to principals explicitly afterwards.
  • Assigning a credential is choosing which key a new grant serves. While a provider holds a single active developer credential the choice is implicit, however many end users’ own keys it also holds (those are never assigned). Once it holds several developer credentials, a new grant must name its credential (credential_id in the SDK, --credential-id in the CLI, the credential picker in the dashboard); without it the call is refused with the conflict error credential_id_required, which lists the provider’s credentials as {id, label} pairs to choose from.
  • Deleting a credential revokes every grant it serves (and every agent delegation under them) and destroys the stored key. It is irreversible, so it requires typing the credential’s label to confirm.

Every grant item returned by list_grants() / listGrants() carries the credential it serves (credential_id, credential_kind, credential_label), so grants backed by the same key can be grouped the same way OAuth grants on one connection are.

A credential is one of two kinds (credential_kind): developer, a key the application stores and assigns, and end_user, a key an end user stored for themselves.

For a provider that issues API keys instead of running OAuth, each end user can store their own key, and the application then uses it on their behalf exactly as it uses the user’s OAuth connection. The application asks for it with the same Connect session it uses to ask for a delegation, naming the provider by its slug:

session = await app.create_managed_secret_connect_session(
provider="acme-api", # the provider's slug
user_token=user_jwt, # the user's identity-provider JWT
agent=AGENT_ID, # optional: also let this agent use the key
)
# session.path is "key_entry" when the user holds no grant on the provider yet,
# "fast_path" when they do (the page then only asks them to approve), and
# "repair" when their own key needs replacing. Send the user to
# session.connect_url.

On the hosted Connect page the user signs in through the application’s identity provider, then types the key, with the scopes, instructions and host patterns the application set for the provider (below), the provider’s own setup steps, and a link to its console. Where the provider can verify a key, a key it rejects is refused on the page and the user can correct it (five attempts per session); a key that could not be verified is accepted and marked as not verified. The user can then set their own limits on it (a quota, active hours, which operations), approve, and is done. Nothing is stored until they approve.

What the key creates is the managed-secret twin of an OAuth connection:

  • One key per user per provider. The user’s key is a credential of kind end_user, and it backs one root grant whose principal is that user. Its account_identifier and account_display_name are the subject and email the user signed in with. With agent, approving also delegates that grant to the agent, as a Connect delegation always does.
  • The application uses it like any of the user’s grants. app.request(..., provider="acme-api", user_token=user_jwt) resolves it by the user’s JWT, or by grant_id. A user who holds both a grant the application assigned to them and their own key on the same provider has two matching grants, so a call naming only the provider raises AmbiguousGrantError; narrow it with label, account or grant_id.
  • It belongs to the user. The user sees the key in the Wallet, with its delegations, and can set limits on it, delegate it to other agents, or remove it. The application can revoke it, which destroys the key, and can see that it exists, but cannot read it, rotate it, rename it, assign it to anyone else (end_user_credential_not_assignable), edit its grant or put a grant-level rule on it (grant_user_owned). The same holds for any delegation a user consented to, from a developer key too. Rules the application sets on the provider, the user, an agent or the app still apply to it.
  • A limit makes it proxy-only. Once the user sets any limit on the key, it is used only through Alter’s proxy, so that every call counts against the limit (Restricted grants are proxy-only). The Connect page tells the user so before they store the key.
  • Replacing it keeps everything else. When the key stops working, a session with repair_grant_id=<the grant> asks the user for a new key; the grant_id, its limits and its delegations stay as they were. A session for a user whose own key’s grant lapsed by its lifetime is a repair without asking, and approving makes the grant active again.

A session created with an agent’s own key reports no path (and no existing_grant_id): session creation never tells an agent what a user holds, and the page decides when the user arrives. requested_grant={"label": …} labels the key and the grant a new key creates (without it the key gets a default label and the grant none), and on the fast path picks the user’s grant with that label when they hold several.

The application configures the key-entry page per provider, in the dashboard’s End-user key setup card or with alter managed-secrets set-end-user-key-setup:

  • Scopes — the vendor permissions the key should have, shown to the user as guidance (Alter cannot check a vendor scope, so a key is never refused for lacking one). For a provider that publishes its scope catalog (Rippling), the choices are its scope ids, with descriptions.
  • Instructions — plain text shown above the key field.
  • Binding host patterns — only for a provider whose API host comes from the key itself (a per-deployment URL, for example). Every host a user’s key points at must match one of these patterns (an exact host or a *. wildcard), when the key is stored and on every call made with it. Until patterns are set, users cannot store a key for that provider, and creating a session that would ask for one fails with binding_hosts_required. Narrowing or clearing the patterns later stops every stored key whose host is no longer allowed, until a pattern allows it again: its proxied calls to such a host are refused as a blocked destination, and the keys themselves are kept.

Each credential of a provider is issued as one or more grants, each bound to a different principal. The bindings decide who can use the credential:

Bound toWho can use itTypical use
UserOne named user, resolved by their JWTA per-user Stripe API key
GroupMembers of an IDP groupA shared Datadog key for everyone in the support group
SystemThe app itself (no user in scope)A background-job AWS credential
AgentOne named agentAn agent-owned Tavily API key

The same credential can back many grants. Issuing a new grant against an existing credential is a metadata operation; the underlying key stays put. A user’s labelled grants on one provider are unique, so provider + label resolves one grant; a duplicate label raises SiblingLabelConflictError.

Managed secrets ship with templates for common header shapes:

TypeWhat Alter injects
Bearer tokenAuthorization: Bearer <token>
API key (custom header)A configured header name, e.g. X-API-Key: <key>
Basic authAuthorization: Basic <base64(user:pass)>
AWS SigV4A full AWS Signature Version 4 computed per request

For providers not in the catalog, the Custom template supports any header name, any injection format, and multi-header or query-parameter injection — see Connect a custom API.

When a key at the provider rotates, update that credential’s stored value in the developer portal (or alter managed-secrets credentials rotate). Every existing grant_id served by that credential keeps working — the key is replaced in place; the grant identity is unchanged. Rotating one credential never affects grants served by the provider’s other credentials.

Once stored, a managed-secret credential can never be read back through a public portal or SDK method. Its only use is credential injection for an outgoing request authorized by a grant. In retrieve mode the SDK process briefly holds the credential while making that request; proxy mode keeps it on Alter’s backend. This eliminates “the operator emailed the API key” as a credential exposure path.

A connection is the underlying stored credential. One connection can back many grants — for OAuth, one connection per (user, provider, account) holds the encrypted token material; for a managed-secret provider, each of its credentials plays the same role. The attached grants are the bindings that let principals exercise it.

This distinction matters most at revocation. Revoking a grant tears down one binding; the credential survives if other grants reference it. Revoking the underlying connection (the Wallet’s “Disconnect”) tears down every grant against it at once.

A user owns one connection per (provider, account) pair, and any number of grants against it — see the next section.

A grant is credential + policy. Connecting an account once produces the connection and a first grant; additional sibling grants can then be minted on the same connection without another OAuth flow. Each sibling carries its own:

  • Label — the sibling’s address. provider + label resolves a grant the same way grant_id does, and the label is unique among the connection’s active grants.
  • Policy — TTL, human-in-the-loop approval, and restrictions (allowed HTTP methods and/or endpoint path patterns). A sibling can only tighten access relative to the connection — never widen it.
  • Delegations — which agents may use it.

Revoking one sibling never touches the others; the stored credential is destroyed only when the last grant on the connection is revoked.

readonly = await app.mint_grant(
source_grant_id,
label="readonly",
grant_policy={"restrictions": {"allowed_methods": ["GET", "HEAD"]}},
)

The source grant is the proof of access: the new sibling shares its connection and principal. Minting is available to the developer (SDK) and to the end user (in the Wallet, via “New grant on this connection” — optionally delegating it to an agent in the same step). Agents cannot mint; they use the grants delegated to them. A sibling does not inherit policy rules attached to its source grant, so the SDK refuses to mint from a source grant that carries grant-level rules (PolicyViolationError, error mint_source_has_grant_rules).

A duplicate label raises the typed SiblingLabelConflictError. Revoking a sibling frees its label.

A grant whose policy carries restrictions can only be exercised through Alter’s server-side execution path, where the method and endpoint of every call are checked before the provider is contacted. Raw-token retrieval against a restricted grant raises the typed RestrictedGrantRequiresProxyError — a token handed to the caller could not be held to the restriction. There is no opt-out.

The same holds once the end user sets any limit on their grant — a request quota, active hours, a request match — from the Wallet or on the Connect page: the grant is then proxy-only, and raw retrieval raises RestrictedGrantRequiresProxyError with the code user_limits_require_proxy. A limit binds only on calls Alter sees, so a raw credential handed out once would be limited once and never again. This applies to OAuth grants and managed-secret grants alike. A quota or active-hours rule the application sets does not make a grant proxy-only; the application’s restriction and approval rules do, as above.

# By label — the human-stable selector:
await app.request("GET", "https://api.github.com/user",
provider="github", label="readonly")
# By grant_id — the production path, as always:
await app.request("GET", "https://api.github.com/user", grant_id=readonly.grant_id)

With several active siblings, provider alone is ambiguous by design: the call raises AmbiguousGrantError whose candidates list your own matching grants with their labels. Alter never guesses.

A user connects GitHub once. The developer wants a research agent that can only read, and a publishing agent that can also write:

  1. The user’s original consent produces the connection and the default grant.
  2. Mint label="readonly" with allowed_methods: ["GET", "HEAD"], and label="publisher" with no method restriction but a 30-day TTL.
  3. Delegate readonly to the research agent and publisher to the publishing agent (each delegation is a one-click user consent).
  4. Each agent calls agent.proxy_request(..., provider="github") — delegated grants are proxy-only, and inside each agent’s world there is exactly one GitHub grant, so resolution is unambiguous. The research agent’s POST attempts are denied before they ever reach GitHub.
  5. Revoking the publisher sibling ends the publishing agent’s access; the research agent and the user’s own grant are untouched.

When an agent needs access the user hasn’t delegated yet, the Connect session can carry the requested shape:

session = await app.create_connect_session(
allowed_providers=["github"],
user_token=user_token,
agent=agent_id,
requested_grant={
"label": "readonly",
"restrictions": {"allowed_methods": ["GET", "HEAD"]},
},
)

If the user is already connected, the Connect popup skips the OAuth redirect entirely and shows a lightweight approval — the agent’s name, the connected account, and the human-readable access level. When the session delegates to an agent, the user first signs in through the application’s identity provider in that browser (see How a delegation is created); then one click mints (or reuses) the labeled sibling and writes the delegation. An identity provider that cannot sign users in makes the session skip this shortcut. If the user isn’t connected, the normal OAuth flow runs first and the same request applies afterwards.

If a connection’s provider token dies (the user revoked the app at the provider, a password change invalidated sessions), one re-consent heals it: the stored tokens are refreshed in place and every sibling — labels, policies, delegations — is exactly as it was. A reconnect can never silently re-widen access that was deliberately narrowed.

Delegation is a user authorizing a specific agent to use one of the user’s grants. It exists because two security models conflict otherwise: the user owns the credential, but an AI agent needs to call the provider on the user’s behalf — and the agent should never see the credential itself.

Without delegation, an agent that needed Stripe access on behalf of Alice would either (a) hold its own Stripe key (wrong principal, wrong audit trail) or (b) receive user context without an authorization edge (no isolation between the agent’s actions and Alice’s). With delegation, Alice explicitly grants the named agent permission to use her Stripe grant. The credential stays in the vault, the agent stays scoped, and audit events carry both identities.

Delegation is consented to at the same moment the user creates the grant. In a Connect session, the operator passes agent=<agent_id>; the consent screen shows the user the agent’s name from its stored record (never a name the requesting application supplies); on Approve, Alter writes the grant and binds it to that agent.

Only the user can approve. The application or agent that requested the delegation holds the session token and usually the user’s token as well, so neither is proof that the user said yes. The approval must come from the user’s own browser, with proof that it is the user:

  • Managed-secret Connect — the page asks the user to sign in through the application’s identity provider (a silent redirect when the user already has a session there), checks the signed-in account is the one the request is for, and binds the approval to that browser. This needs the identity provider’s sign-in client; without it, creating the session fails with idp_signin_not_configured.
  • OAuth Connect with an agent — connecting an account at the provider is the proof: the user signs in at the provider’s own authorization screen. Only approving on an account the user already connected asks for the identity-provider sign-in above; without a sign-in client, the session sends the user through the provider’s authorization instead.
  • Wallet — delegating from the Wallet needs proof of the user’s own Wallet sign-in; a request carrying only the user’s token is refused.

A failed sign-in never ends a Connect request: if the user signs in as someone else, nothing is granted and the page offers to sign in again, and the user can always cancel. See Approving Connect delegations for the identity-provider setup. The approval’s audit row records how the user proved it, when they signed in, and which application or agent asked. Onward delegations between agents need no new sign-in: the user’s delegable choice at the first hop authorizes them, within the operator’s ceiling.

A user can delegate a grant to many agents, and an agent can receive delegations from many users (the agent ends up able to act on behalf of each).

The first delegation — user to agent — is one hop. An agent that holds a delegated grant can, in turn, delegate it onward to a second agent, forming a chain: user → agentA → agentB. Each link narrows: a child grant can drop scopes and shorten its lifetime relative to its parent, never widen them. The credential never enters an agent process at any hop; every agent in the chain calls the provider through Alter’s proxy, and the audit trail records which agent acted under whose authority at each link.

Onward delegation is opt-in at every hop, never automatic. A grant carries a delegable flag, chosen when the grant is created:

  • For an OAuth connection requested through Connect, the application asks for it — app.create_connect_session(..., agent=<agent_id>, delegable=True) — and the user decides on the consent screen, with the same May share this access with other agents checkbox and rules as for a managed-secret provider (below). A session that asks always shows that screen, even when it set direct=True, and the session reports direct as False. The checkbox appears only when the provider’s agent sharing setting allows onward sharing (see Operator ceilings), except that an existing delegation that can already be shared onward keeps it, turned on, when the setting no longer allows that, and the user can then only turn it off. The choice is the user’s alone, so the user signs in through the application’s identity provider before making it: the confirmation screen that follows a provider login shows Sign in to choose in place of the checkbox until the user signs in. Embedded Connect shows that confirmation screen in the window where the user signed in to the provider, so the sign-in and the checkbox are offered there too. Where a screen cannot run the sign-in (one shown inside the embedded Connect frame, such as the approval step for an account the user already connected, or an identity provider that cannot sign users in), it offers no checkbox: a new delegation is created as a leaf, and an existing one keeps its setting. An explicit delegable=False is stored as given and shows no checkbox. In the Wallet, the user decides it when minting a grant for an agent. The result of the connection reports the stored setting on ConnectResult.delegation (delegable), and the popup message and the return_url carry it too.
  • For a managed-secret provider requested through Connect, the application asks for it — delegable=True on app.create_managed_secret_connect_session() or agent.create_managed_secret_connect_session() — and the user decides: the Connect page shows a May share this access with other agents (up to N more hops) checkbox and says next to it who asked for onward sharing: the application, or the agent itself when its own key asked. The checkbox starts from the user’s existing delegation to that agent when there is one; for a new delegation it starts on when the application’s key asked and off when the agent asked with its own key, so an agent never gets onward sharing for itself unless the user turns it on. The checkbox also appears, turned on, when the user’s existing delegation to that agent can already be shared onward. It appears only when the provider’s delegation policy allows onward delegation, except that an existing delegation that can already be shared onward keeps the checkbox, which the user can then only turn off. The page never grants more than the application asked for unless the existing delegation already had it. The approval tells the application the stored setting: the approved postMessage and the return_url query carry delegable (true or false) alongside updated. true is the setting, not a promise: the provider’s delegation policy still decides every onward delegation, so a delegation that kept true after the policy stopped allowing onward sharing cannot share onward until an administrator allows it again.
  • When an agent re-delegates, it independently decides whether its child may delegate further, by setting delegable on the agent.delegate() call.

A grant created without delegable is a leaf: its holder may use it, but cannot pass it on. This makes a chain’s reach an explicit, per-link decision rather than something that propagates by default. Omit delegable to keep the existing delegation’s setting: renewing a delegation (re-consenting, re-approving or re-delegating to the same agent) without delegable never changes whether it can be shared onward, while an explicit False turns that off and later onward delegations from it are refused.

An agent that holds a delegable grant re-delegates it with one call:

child = await agent.delegate(
held_grant_id,
to_agent_id,
delegable=False, # the child is a leaf — it cannot re-delegate
scope_constraint=["read"], # narrow to a subset of the parent's scopes (optional)
ttl_seconds=3600, # shorten the lifetime (optional)
)

The parent is the grant the calling agent already holds — Alter derives it from the caller’s own access, so an agent can only delegate what it actually has. A grant the agent does not hold is indistinguishable from one that does not exist. If the held grant is not delegable (or the credential’s operator ceiling forbids it — see Operator ceilings), the call raises GrantNotDelegableError; the remedy is to request a delegable grant, or ask an administrator to allow sharing, not to retry.

Delegation always starts with a person. A credential granted directly to an agent, with no consenting user, can be used by that agent but never delegated onward: no user would see that chain, revoke it from the Wallet, or end it by leaving the identity provider.

The returned DelegationResult carries the new child’s grant_id, its parent_grant_id, and its depth (distance from the credential root). A scope-narrowed or otherwise delegated grant is proxy-only — exercised through Alter’s server-side execution path, never handed back as a raw token, so the narrowing cannot be bypassed.

Every grant in a list carries parent_grant_id (the grant it was minted under, or None/null for a root that was directly consented) and depth (its hop distance from the root). From the flat list of grants, an application reconstructs the full delegation tree — which agent delegated to which, and how deep each branch runs — without any separate call.

The agent process calls agent.proxy_request() against the provider. Delegated grants are proxy-only so their restrictions cannot be bypassed. The backend:

  1. Resolves the calling agent from the API key signature.
  2. Looks up the delegations visible to that agent.
  3. Picks the grant matching the provider (and optionally the user, if the agent bridged a user_token).
  4. Injects the token from the user’s grant — never the agent’s own credentials, because the agent has none for this provider.

If the agent has delegations from multiple users on the same provider, the agent can disambiguate per-call via the user_token parameter (or by configuring user_token_getter at construction).

Three revocation surfaces, each removing a different layer:

  • User revokes the delegation (in the Wallet) — the agent loses access; the user’s underlying grant stays active.
  • User revokes the underlying connection — both the user’s grant and every delegation against it are torn down.
  • Operator revokes the delegation (app.revoke_delegation(grant_id, agent_id)) — the same effect as the user revoking the delegation, performed admin-side. grant_id may be an OAuth grant or a managed-secret grant, an end user’s own key included.

Revocation cascades down the chain. Revoking a grant tears down not only its direct delegations but every onward hop minted beneath them — a child can never outlive the parent it was delegated from. Revoking the root (or the underlying connection) collapses the entire tree at once. There is no way for a deeper link to survive the loss of a link above it.

The first call by any affected agent after revocation raises NoDelegatedGrantError (or GrantDeletedError if the connection itself was revoked — an agent always receives the reason-neutral terminal, never the typed GrantRevokedError, so a delegated agent cannot probe whether its access was deliberately revoked).

The same model applies to managed-secret grants bound to a user, whether the application assigned the grant or the user stored their own key. The user can consent to delegate a managed-secret grant to an agent via app.create_managed_secret_connect_session(provider=…, agent=…) — or, when the agent asks for itself, agent.create_managed_secret_connect_session(provider=…) — approving it on the Connect page after signing in, as above, or from the Wallet, producing a delegation that lets the agent use the provider on the user’s behalf. Connect and the Wallet apply one rule for which of the user’s grants may be delegated to the agent: the grant must be live, its provider’s delegation policy must allow the delegation (group grants, depth, the grant’s own expiry), and the agent’s capability allowlist must contain the provider’s stable, app-unique slug under managed_secret_slugs (not its display name or template ID), or, for an agent created before slugs, the provider’s display name under the legacy managed_secrets key. The Connect page lists only such grants. When the user holds none, the page asks for their own key instead, and delegates it on approval. A request that could never complete is refused up front for a key bound to no managed agent (the application’s key, or an agent-typed alter_ak_… key from API Keys): no_eligible_grants when the agent’s allowlist or the provider’s delegation policy would refuse the delegation of a new key, and binding_hosts_required or provider_contract_repair_required when the provider cannot accept a key yet. A request from a managed agent’s own key is never refused for those reasons (session creation never tells an agent what a user holds); the page explains instead.

Managed secrets add an operator ceiling on top of per-grant consent. Because the operator — not the end user — owns the underlying credential, the operator decides whether grants under a provider may be delegated at all, and how deep any chain may run. Two controls, set on the provider’s delegation policy at create time or with alter managed-secrets set-delegation-policy:

  • delegable — whether grants under this provider may be marked delegable in the first place. Off by default; a provider that is not operator-delegable refuses every onward delegation regardless of what a user or agent requests.
  • max_delegation_depth — the maximum chain depth permitted under the provider (capped at 8). A delegation that would exceed the ceiling is rejected. A delegation at the last depth the ceiling allows can never be shared further, so asking for it to be delegable (delegable=True on agent.delegate(), or onward sharing on the Connect page or in the Wallet) is refused with grant_not_delegable; an explicit delegable=False is never refused for that reason.

These bounds are a ceiling, not a default: a delegation only succeeds when both the per-grant delegable opt-in and the provider’s operator ceiling allow it. An onward delegation that the ceiling forbids raises GrantNotDelegableError, the same typed error a non-delegable grant produces.

Both kinds of credential carry an operator ceiling on onward sharing, with the same two settings and the same rules:

  • a managed-secret provider’s delegation policy (above);
  • an OAuth provider’s agent sharing setting, one per provider in each application, set on the provider’s page in the dashboard or with alter providers set-delegation-policy. It turns onward sharing on or off for every OAuth grant of that provider in the application, and caps how deep a chain may run (1 to 8; 1 allows the user’s own delegation to an agent and nothing further). A provider configured after this setting was introduced starts with sharing off; providers that existed before it keep sharing on.

While a ceiling is off, the consent screens and the Wallet offer no onward-sharing choice, and every onward delegation of that credential is refused with GrantNotDelegableError, including a renewal of an existing delegation. Turning a ceiling off does not change what was stored: a delegation that kept delegable=True cannot share onward until an administrator allows sharing again, and the Wallet marks it No longer allowed to share this access with other agents. If the ceiling changes while a user is on the consent screen, the screen learns it when the user confirms: saving the choice is refused, the checkbox is redrawn as the setting now allows (hidden, or turned on and only able to be turned off), and the user confirms again. Only a change that lands between saving the choice and the confirmation itself reaches the end of the flow: the connection still completes with the delegation’s stored setting, the success page tells the user that sharing was not turned on, and the result’s delegation.policy_refused tells the application.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.