Skip to content

Reference

Connect & Grants

Connect sessions, grant lifecycle, delegation revoke, managed-secret grants, HITL approvals.

This page covers every surface for getting a credential into the system and managing its lifecycle: the OAuth provider catalog, Connect sessions (user-consent flows), grant CRUD, agent-delegation revoke, managed-secret grant creation, and human-in-the-loop approvals.

# Provider catalog
catalog = await app.oauth_providers.list()
# Connect flows
results = await app.connect(providers=["provider-id"])
session = await app.create_connect_session(allowed_providers=["provider-id"])
results = await app.poll_connect_session(session.session_token)
session = await app.create_connect_session_for_error(caught_error)
session = await app.create_managed_secret_connect_session(...)
session = await agent.create_managed_secret_connect_session(...) # for this agent
# End-user sign-in (IDP login)
auth = await app.authenticate() # CLI convenience
auth_session = await app.create_auth_session() # headless / remote user
result = await app.poll_auth_session(auth_session.session_token)
# Grants
page = await app.list_grants()
minted = await app.mint_grant(source_grant_id, label="read-only")
await app.revoke_grant(grant_id, reason="rotation")
result = await app.create_managed_secret_grant(...)
# Delegations
await app.revoke_delegation(grant_id, agent_id) # operator
await agent.revoke_delegation(grant_id) # agent self-revoke
# Approvals
final = await app.await_approval(approval_id, timeout=600.0)
status = await app.get_approval_status(approval_id)

Fetch the OAuth provider catalog: the connectable providers configured for the platform and, per provider, the scopes you can request. Use it to drive provider pickers and scope selection instead of hardcoding scope strings.

async def list(*, force_refresh: bool = False) -> OAuthProviderCatalog
ParameterTypeDefaultDescription
force_refreshboolFalseBypass the in-process cache and re-fetch.

Results are cached in-process for 5 minutes. The catalog lists only active (connectable) providers. Available on both App and Agent as app.oauth_providers / agent.oauth_providers.

Returns: OAuthProviderCatalog — providers keyed by id, plus get_default_scopes(provider) / get_required_scopes(provider) helpers.

Raises: BackendError (malformed catalog response), AlterSDKError, NetworkError. Requires the providers:read scope.

from alter_sdk import App
app = App(api_key="alter_rk_…")
catalog = await app.oauth_providers.list()
provider = catalog.providers["<provider_id>"]
print(provider.display_name, provider.default_scopes)
# Pre-fill a Connect flow with the provider's default scopes.
scopes = catalog.get_default_scopes("<provider_id>")

Headless OAuth flow for CLI tools, notebooks, and backend scripts. Mints a session, opens the browser, polls until the user completes consent.

async def connect(
*,
providers: list[str] | None = None,
timeout: int = 300,
poll_interval: float = 2.0,
open_browser: bool = True,
grant_policy: GrantPolicyInput | dict[str, Any] | None = None,
allow_user_policy_rules: bool = True,
direct: bool = False,
) -> list[ConnectResult]
ParameterTypeDefaultDescription
providerslist[str] | NoneNoneRestrict to specific providers. None allows any provider configured on the app. An empty list or a blank entry raises AlterValueError before any request is sent.
timeoutint300Positive, finite maximum seconds to wait for completion. An explicit value is honored; it does not shorten the server session. Elapsed time uses a monotonic clock.
poll_intervalfloat2.0Positive, finite seconds between poll requests.
open_browserboolTrueTrue opens the URL in the default browser; False prints the URL for the user to open manually (headless servers).
directboolFalseSkip the end user’s confirmation screen, duration choice and in-flow policy step, closing the page on delivery. Requires exactly one entry in providers; raises AlterValueError locally otherwise. Never implied by the provider count.
grant_policyGrantPolicyInput | dict[str, Any] | NoneNoneTTL bounds: max_ttl_seconds (ceiling), default_ttl_seconds (pre-selected).
allow_user_policy_rulesboolTrueWhether the end user may set their own usage limits, that is policy rules (deny, require-approval, time window, request quota, or operation-parameter content rules), in the Connect popup while authorizing. Forwarded to the Connect session. The limits are applied as part of authorizing the connection; if they cannot be applied, the connection is revoked rather than left in place without them. Set False to hide the policy step. Has no effect on a session minted with direct, which has no confirmation screen; naming one provider alone does not remove it. Must be a boolean; a non-boolean raises AlterValueError at the SDK boundary.

Returns: list of ConnectResult — one per provider the user authorized. On partial completion, every result carries the same typed failed_grants list (provider_id, reason, message) for providers the session connected but did not keep: reason is "not_selected" when the user did not select the provider on the Confirm screen, "activation_failed" when the connection could not be completed as the user confirmed it, and another value when the selected usage limits could not be applied. The list is empty for full success; unknown reason strings are preserved. When the Alter deployment holds new connections until Confirm, a connection becomes usable only when the user confirms it, so a user who closes the page after the provider sign-in without confirming leaves no usable grant, and the poll ends in ConnectTimeoutError. On a deployment that does not, such a connection is usable as soon as the provider sign-in completes, without the usage limits or expiry the user had not yet confirmed. delegation is always None on these results: connect() takes no agent, so its session delegates to no agent (use create_connect_session() with agent and poll_connect_session() for that).

Raises: AlterValueError, ConnectTimeoutError, ConnectDeniedError, ConnectConfigError, ConnectFlowError, BackendError, AlterSDKError, NetworkError. ConnectTimeoutError.details["reason"] is "poll_deadline_elapsed" when the local deadline wins or "session_expired" when a session observed pending expires before Alter receives a callback. ConnectConfigError includes the typed provider_configuration_unavailable, shared_dev_credential_unavailable, and managed_oauth_scope_approval_required terminals returned when an app cannot start a fresh provider authorization. The last requires operator approval followed by a new Connect session. ConnectFlowError also represents a completed flow that kept no grant; error.details["failed_grants"] contains the typed failures in snake_case form, and error.details["error_code"] is grant_policy_application_failed when any grant was revoked because the selected usage limits could not be applied, or grant_activation_failed when every selected connection instead could not be completed at Confirm. BackendError is raised for an identity-synchronization refusal from polling, split by status_code: 503 is transient identity-provider sync unavailability (retry later; details["retry_after_seconds"] carries a backoff hint when present), while 409 means the identity provider is inactive or a group name is ambiguous — conditions an app operator must repair, so retrying does not help. details["error_code"] names the terminal.

from alter_sdk import App
app = App(api_key="alter_rk_…")
results = await app.connect(providers=["provider-id"], timeout=180)
for r in results:
print(r.grant_id, r.provider_id, r.account_identifier)
for failed in r.failed_grants:
print("Not connected:", failed.provider_id, failed.message)

Mint a Connect session and receive the connect_url. Use when the UI hosts its own redirect / popup / mobile flow rather than the headless connect() browser bridge. In a native app, open connect_url in the system browser rather than an embedded WebView: identity providers refuse or cannot share their sign-in session with embedded WebViews, and approving a delegation on an account the user already connected asks the user to sign in first.

async def create_connect_session(
*,
allowed_providers: list[str] | None = None,
return_url: str | None = None,
allowed_origin: str | None = None,
metadata: dict[str, Any] | None = None,
grant_policy: GrantPolicyInput | dict[str, Any] | None = None,
required_scopes: dict[str, list[str]] | None = None,
agent: str | None = None,
user_token: str | None = None,
requested_grant: dict[str, Any] | None = None,
repair_grant_id: str | None = None,
delegable: bool | None = None,
scope_constraint: list[str] | None = None,
switch_account: bool = False,
allow_user_policy_rules: bool = True,
) -> ConnectSession
ParameterTypeDefaultDescription
allowed_providerslist[str] | NoneNoneRestrict to specific providers. Exactly one entry skips the provider picker — there is nothing to choose — and sends the user straight to that provider’s sign-in, with no Alter-branded page before or after it. The response’s single_provider flag is True; passed to the browser widget as open({ container, singleProvider }) from the user’s click, the inline widget opens the provider window immediately too (a single tile only if the browser blocks the window). Two or more entries, or omitting the field, render the picker. The end user still confirms the connection on the way back; to skip that as well, see direct. An empty list or a blank entry raises AlterValueError before any request is sent (omit the parameter to offer every configured provider). Entries are matched case-insensitively after trimming, so ["GitHub", "github"] counts as one provider.
directboolFalseOpt the session out of the end-user consent screens: no confirmation screen, no duration choice and no in-flow policy-authoring step, and the page delivers the outcome and closes instead of showing the user what happened. Requires exactly one provider in allowed_providers and raises AlterValueError locally otherwise — with a picker on screen the flag contradicts itself. Never implied by the provider count: how many providers an application offers says nothing about whether the person granting access should still see and shape what they are granting. On a direct session allow_user_policy_rules and session grant-expiry bounds add no end-user step, so the grant is issued with the requested scopes and the default duration; the application’s own grant_policy bounds still apply. A session that delegates to an agent and sets delegable=True is never direct: the user decides onward sharing on the confirmation screen, so the screen is shown and the session reports direct as False.
return_urlstr | NoneNoneURL to redirect after completion.
allowed_originstr | NoneNoneAllowed origin for postMessage communication in popup flows.
metadatadict | NoneNoneRequest metadata (ip_address, user_agent) recorded with the session.
grant_policyGrantPolicyInput | dict[str, Any] | NoneNoneTTL bounds. Accepts a GrantPolicyInput model or a plain dict; either way the keyset is validated — a typo (maxTtlSeconds instead of max_ttl_seconds) raises AlterValueError instead of silently dropping the TTL.
required_scopesdict[str, list[str]] | NoneNonePer-provider scope ceiling. Each scope must be in the app’s configured allowlist; the backend rejects attempts to widen with scope_not_allowed.
agentstr | NoneNoneManaged-agent UUID or name. When set, the resulting grant is delegated to this agent, and the approval must come from the end user. Connecting an account at the provider proves that on its own; approving on an account the user already connected (the one-click shortcut) first shows a Sign in to approve step, a sign-in through the application’s identity provider in the browser that approves. When the identity provider cannot sign users in (no wallet client ID and secret), the session skips the shortcut and sends the user through the provider’s own authorization instead. A failed sign-in never ends the session: the page lets the user sign in again or cancel, and no sign-in failure code reaches the application — poll_connect_session() and the Connect widget see only the session’s outcome. While Alter is being upgraded, a session created by the previous version also skips the shortcut and sends the user through the provider’s own sign-in; if the page’s own sign-in start refuses such a session, it shows session_outdated to the user only — the code never reaches the application or an SDK. Who may be named depends on the key: an app key (including an Agent from App.get_agent()) names any active agent of the app. An agent’s own key requests a delegation only for itself: omit agent (with a user_token) or pass the calling agent’s own id or name. Naming another active agent of the app raises BackendError with HTTP 400 agent_cannot_delegate_to_other_agent, and an id or name that matches no active agent raises AgentNotFoundError (HTTP 404 agent_not_found, details["error"] == "agent_not_found"). Every such refusal is recorded in the audit trail under the calling agent, never under the agent it named.
requested_grantdict | NoneNoneSibling-grant request (multi-grant model). A dict with a required label (the sibling address — resolution key is provider + label) and an optional restrictions dict carrying allowed_methods and/or allowed_endpoints lists. The grant created by this session carries the label and (when set) the restrictions, which make it proxy-only. Unknown keys raise AlterValueError at the SDK boundary.
repair_grant_idstr | NoneNoneReauthorize this active grant in place. Requires exactly one allowed_providers entry; cannot be combined with agent, requested_grant, or switch_account. Normally supplied automatically by create_connect_session_for_error() after CredentialRevokedError.
delegablebool | NoneNoneOnly meaningful with agent. True asks that the agent may share this access with other agents through agent.delegate(); the user decides on the consent screen, which shows a May share this access with other agents checkbox (on by default when the application’s key asks, off when the agent asks with its own key) and only the user’s choice is stored. The checkbox appears only when the provider’s agent sharing setting allows onward sharing, 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 user signs in through the application’s identity provider before choosing: the confirmation screen that follows a provider login shows Sign in to choose first. 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 that 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), no checkbox is offered, a new delegation is created as a leaf, and an existing one keeps its setting. True also shows the consent screen for a direct session. False stores False, with no checkbox, and on a re-consent turns an existing delegation’s onward capability off. Omit delegable to keep the existing delegation’s setting: re-consenting for an agent that already holds a delegation on this account keeps its current value unless the user changes it, and a new delegation is created as a leaf the recipient cannot pass on (onward delegation is opt-in at every hop). The stored outcome is on the result’s delegation. The SDK sends the field only when it is set.
scope_constraintlist[str] | NoneNoneRequires agent (a constraint with no delegation to narrow is rejected). Narrow the delegated agent’s first-hop grant to a subset of the credential’s provider scopes (least privilege). None = full credential scope. Provide a non-empty list of at most 100 concrete scope strings, each ≤ 512 characters and a valid RFC 6749 scope-token (printable ASCII — no spaces, " or \; one atom per scope); "*", empty strings, and an empty list are rejected — pass None (or omit) for full scope. Every atom must be within at least one allowed provider’s effective scopes for the session (a broader scope that subsumes the atom qualifies): a constraint invalid for every allowed provider fails session creation with BackendError (HTTP 422, invalid_scope_constraint); a constraint invalid for only some providers succeeds and ConnectSession.scope_constraint_warnings carries one diagnostic per affected provider — completing consent for such a provider fails with the same typed error. A scope-narrowed grant is proxy-only — call it with proxy_request().
user_tokenstr | NoneNoneExplicit user JWT for identity binding. Wins over user_token_getter. Omit both for an unbound (app-scoped) session: the hosted flow still completes, but the resulting grant belongs to the application rather than to any end user — JWT resolution will not find it, so call it with provider= or its grant_id from a call that carries no user token (with one in scope, the backend rejects an app-scoped grant: system_grant_user_token_mismatch). Required when agent is set and no user_token_getter is configured: a session with neither is rejected with BackendError HTTP 400 agent_requires_user_token, because a delegation needs a consenting user.
switch_accountboolFalseForce the full provider authorization flow, skipping the already-connected fast path — the programmatic equivalent of the consent screen’s “Use a different account” link. Set it when the flow’s purpose is connecting a different account for a provider the application already holds a connection to. Must be a boolean — a non-boolean raises AlterValueError at the SDK boundary.
allow_user_policy_rulesboolTrueWhether the end user may set their own usage limits, that is policy rules (deny, require-approval, time window, request quota, or operation-parameter content rules), in the Connect popup while authorizing. The limits are applied as part of authorizing it; if they cannot be applied, the connection is revoked rather than left in place without them. Default True; set False to hide the policy step. Has no effect on a session minted with direct, which has no confirmation screen; naming one provider alone does not remove it. Must be a boolean; a non-boolean raises AlterValueError at the SDK boundary.

Returns: ConnectSession.

Raises: AlterValueError, ConnectConfigError, BackendError, AlterSDKError, NetworkError, TimeoutError. AlterValueError covers the boundary guards named in the parameter table — an empty or malformed allowed_providers or a blank entry in it, a non-boolean switch_account / allow_user_policy_rules / direct, direct without exactly one provider, a malformed grant_policy / requested_grant keyset, or an invalid scope_constraint atom — and fires before any request is sent. BackendError covers backend rejections, including scope_not_allowed when a requested scope is outside the app’s configured allowlist, HTTP 422 invalid_scope_constraint when the scope_constraint is invalid for every allowed provider, HTTP 400 invalid_origin when allowed_origin is not a single origin, and the delegation-target refusals in the agent and user_token rows (agent_cannot_delegate_to_other_agent, agent_requires_user_token); the code is in details["error"]. AgentNotFoundError (a BackendError subclass) is raised when agent matches no active agent of the app; for an Agent from App.get_agent() whose agent is no longer active, the caller check refuses first, with details["error"] == "caller_agent_unresolved". AlterSDKError is raised when the SDK client is closed; NetworkError and TimeoutError cover transport failures.

ConnectConfigError carries the typed managed_oauth_scope_approval_required terminal (HTTP 409): the requested scopes exceed what the Alter-managed OAuth client is approved for. It is not a BackendError, so a handler that branches only on BackendError leaves it uncaught. Contact Alter to approve the scopes, or narrow the app’s configured scopes to the approved set, and only then create a new session — the response is retryable: false and every fresh session fails identically until the approval changes.

On this call the exception’s details is the full 409 body, which is the only way to tell an operator which scopes need approving:

from alter_sdk import ConnectConfigError
try:
session = await app.create_connect_session(allowed_providers=["provider-id"])
except ConnectConfigError as e:
if e.details.get("error") == "managed_oauth_scope_approval_required":
blocked = e.details["details"] # {"provider_id": ..., "unapproved_scopes": [...]}
remediation = e.details["remediation"] # {"action": ..., "description": ...}
logger.error(
"Alter approval needed for %s: %s — %s",
blocked["provider_id"],
blocked["unapproved_scopes"],
remediation["description"],
)
# e.details["retryable"] is False — do not retry.
raise
session = await app.create_connect_session(
allowed_providers=["provider-id"],
allowed_origin="https://app.example.com",
grant_policy={"max_ttl_seconds": 86400, "default_ttl_seconds": 3600},
)
return {"connect_url": session.connect_url, "session_token": session.session_token}

Poll a Connect session token to completion. Used when the application minted the session itself and needs to block until the user finishes consent.

async def poll_connect_session(
session_token: str,
*,
timeout: int = 300,
poll_interval: float = 2.0,
on_event: Callable[[dict[str, Any]], None] | None = None,
) -> list[ConnectResult]
ParameterTypeDefaultDescription
session_tokenstr—Non-empty token from create_connect_session() or create_connect_session_for_error(). Positional.
timeoutint300Positive, finite maximum seconds to wait. An explicit value is honored; it does not shorten the server session. Elapsed time uses a monotonic clock.
poll_intervalfloat2.0Positive, finite seconds between polls.
on_eventCallable[[dict[str, Any]], None] | NoneNoneSynchronous callback fired once per provider the user declines while the session remains recoverable (a multi-provider picker where another provider can still be chosen). Receives {"type": "provider_declined", "provider_id": ..., "error_code": ..., "declined_at": ...} — declined_at is an ISO-8601 timestamp string, or None when the service omits it. Exceptions it raises are logged and never interrupt the poll; a coroutine (async def) callback is rejected with AlterValueError at call time.

Returns: list of ConnectResult. Partial completions expose the session’s typed failures on each result as failed_grants, including reason="not_selected" for a provider the user did not select on the Confirm screen and reason="activation_failed" for one that could not be completed when the user confirmed it. When the Alter deployment holds new connections until Confirm, a connection becomes usable only when the user confirms it: a session the user leaves after the provider sign-in without confirming ends as expired, and none of its connections is usable. On a deployment that does not, such a connection is usable as soon as the provider sign-in completes, without the usage limits or expiry the user had not yet confirmed. A grant whose session delegated to an agent carries delegation (agent_id, delegable, policy_refused): the delegation’s stored onward-sharing setting after the user’s approval, and whether the provider’s agent sharing setting refused the user’s choice to share when the connection was confirmed (the setting changed while the consent screen was open; delegable is then the setting that was kept).

Raises: AlterValueError, ConnectTimeoutError, ConnectDeniedError, ConnectConfigError, ConnectFlowError, PolicyViolationError, BackendError, AlterSDKError. Invalid token/poll controls fail before any request; malformed terminal error fields fail as BackendError. ConnectTimeoutError.details["reason"] is "poll_deadline_elapsed" when this call’s deadline wins, "session_expired" when a session observed pending expires before Alter receives a callback, or "rate_limited" when the whole budget was spent backing off a throttled poll (details["retry_after"] carries the last hint the service gave). PolicyViolationError with policy_error="ip_not_allowed" is raised when the API key is restricted to an IP range that the polling host is outside of. ConnectConfigError includes the typed provider_configuration_unavailable, shared_dev_credential_unavailable, and managed_oauth_scope_approval_required terminals returned when an app cannot start a fresh provider authorization. For managed OAuth approval, an operator must approve the requested permissions before the application creates a new Connect session. An unavailable/expired token on the first poll stays ConnectFlowError because the API deliberately does not reveal whether a session existed. ConnectFlowError is also raised when a completed flow kept no grant; its details["failed_grants"] list identifies each provider and reason, and details["error_code"] is grant_policy_application_failed (selected usage limits could not be applied) or grant_activation_failed (every selected connection could not be completed at Confirm). BackendError is also raised for an identity-synchronization refusal, split by status_code: 503 is transient identity-provider sync unavailability (retry later; details["retry_after_seconds"] carries a backoff hint when present), while 409 means the identity provider is inactive or a group name is ambiguous — conditions an app operator must repair, so retrying does not help. details["error_code"] names the terminal.

session = await app.create_connect_session(allowed_providers=["provider-id"])
# … hand connect_url to the user …
results = await app.poll_connect_session(session.session_token, timeout=600)

Denials while the session stays open. On a picker offering several providers, declining one is not terminal — the user can still choose another. Those denials are observable three ways: on_event fires as each one is seen, a timeout after denials lists them in ConnectTimeoutError.details["declined_providers"] (on every timeout reason, "session_expired" and "rate_limited" included), and once the user has declined every offered provider — leaving nothing else to try (an already-connected account still awaiting approval counts as something to try) — the session ends with ConnectDeniedError instead of leaving the poll waiting.

Mint a recovery Connect session directly from a caught error. The method threads provider_id from the error into allowed_providers=[...], agent_id (when present) into agent=, and a CredentialRevokedError.grant_id into the internal repair target. A successful credential repair therefore preserves the existing grant and returns operation="reauth" instead of creating an ambiguous duplicate.

async def create_connect_session_for_error(
error: NoDelegatedGrantError | GrantNotFoundError | CredentialRevokedError,
*,
allowed_origin: str | None = None,
return_url: str | None = None,
metadata: dict[str, Any] | None = None,
grant_policy: GrantPolicyInput | dict[str, Any] | None = None,
required_scopes: dict[str, list[str]] | None = None,
user_token: str | None = None,
) -> ConnectSession
ParameterTypeDefaultDescription
errortyped exception—The caught exception. Must carry provider_id.
allowed_origin / return_url / metadata / grant_policy / required_scopesStandard create_connect_session parameters. Not inferred from the error.
user_tokenstr | NoneNoneExplicit user JWT. Falls back to user_token_getter.

Raises: AlterValueError if error.provider_id is None (no recovery context), or if a NoDelegatedGrantError has no agent_id set. The call then forwards to create_connect_session(), so it also raises everything that method raises — including ConnectConfigError with managed_oauth_scope_approval_required when the Alter-managed OAuth client is not approved for the scopes this app requests. That terminal is not recoverable by retrying: contact Alter to approve the scopes, or narrow the app’s configured scopes to the approved set, and only then mint a recovery session. See the create-session section above for the details payload naming the unapproved scopes.

from alter_sdk import NoDelegatedGrantError
try:
resp = await agent.request("GET", url, provider="provider-id")
except NoDelegatedGrantError as e:
session = await agent.create_connect_session_for_error(
e,
allowed_origin="https://app.example.com",
)
redirect_user_to(session.connect_url)
results = await agent.poll_connect_session(session.session_token)
# Retry with results[0].grant_id

app_user_id is intentionally not threaded through — create_connect_session binds the session to a user via user_token, not app_user_id. Pass user_token= explicitly when user binding is required.

Mint a Connect session for the user→agent delegation flow on a managed secret. Symmetric to create_connect_session(), but for managed-secret credentials instead of OAuth. An agent asks for its own delegation with Agent.create_managed_secret_connect_session(), which takes the same parameters without delegated_agent_id.

async def create_managed_secret_connect_session(
*,
template_slug: str,
delegated_agent_id: str,
user_token: str,
requested_ttl_seconds: int | None = None,
allowed_origin: str | None = None,
return_url: str | None = None,
allow_user_policy_rules: bool = True,
delegable: bool = False,
) -> ManagedSecretConnectSession
ParameterTypeDefaultDescription
template_slugstr—Canonical template slug (e.g. "provider-api-key"). Kebab-case required.
delegated_agent_idstr—Agent UUID the user is being asked to authorize. Must belong to the calling app and be active; otherwise the call raises AgentNotFoundError (HTTP 404 agent_not_found), never a GrantNotFoundError. Required on App, because an app key has no agent of its own: None, an empty string or a non-UUID value raises AlterValueError before any request, and omitting the argument is a TypeError.
user_tokenstr—IDP JWT identifying the consenting user. Required — managed-secret delegation has no system-principal fallback.
requested_ttl_secondsint | NoneNoneCaller-suggested TTL. Effective TTL is the minimum of requested, template max, source expiry, and 90 days.
allowed_originstr | NoneNonePopup-flow postMessage origin. It must be a single origin — a scheme, a host and an optional port, with no path, query, fragment, credentials or wildcard — and in production it must use https:// unless the host is localhost or a loopback IP address. A lone trailing / is removed. Anything else, including *, an empty string and a value without a scheme, raises BackendError with HTTP 400 invalid_origin. A value longer than 300 characters raises BackendError with HTTP 400 invalid_origin too, as on create_connect_session. There is no fallback to the app’s website URL: omit the parameter when there is no popup opener.
return_urlstr | NoneNoneMobile-redirect return URL.
allow_user_policy_rulesboolTrueWhether the consenting user may add self-scoped, narrowing-only usage limits in the hosted approval flow. The rules are attached atomically to the delegated managed-secret grant; approval fails if they cannot be applied. Set False to hide the policy step. A non-boolean raises AlterValueError before any request.
delegableboolFalseAsk the user to let the agent share this access with other agents through agent.delegate(). With True, the Connect page shows the user 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 delegation stores the user’s choice. N is how many more delegation steps the secret’s policy allows below this one. The checkbox appears only when the selected credential’s policy allows onward delegation with at least one more hop, except that an existing delegation that can already be shared onward keeps the checkbox, which the user can then only turn off (below); so the user can never turn onward sharing on past the operator’s ceiling. It starts from the user’s existing delegation to this agent when there is one, so a re-consent never turns onward sharing on unless the user does; for a new delegation it starts on, except when the agent asked with its own key (Agent.create_managed_secret_connect_session()), where it starts off so the user opts the agent in. With False the page shows the checkbox only when the user’s existing delegation to this agent can already be shared onward, turned on to keep it; a new delegation is created as a leaf. When that existing delegation can be shared onward but the policy no longer allows it (the ceiling was lowered, or no hops are left), the checkbox is still shown, turned on, and the user can only turn it off. The approval reports the stored setting as delegable (see Returns). Echoed on the result as delegable_requested. Requires a backend that supports it: an older one ignores the field, so the SDK raises ServerFeatureUnsupportedError rather than return a session that would create a leaf.

The consent page names the agent from its stored record (its display name, else its name); the requesting application cannot supply a name. Passing the removed delegated_agent_name keyword raises TypeError.

The user must hold something to delegate. Before creating the session, Alter checks that the user holds a credential the delegation could use: an active grant for a secret created from template_slug, held directly or through a group, that the secret’s delegation policy lets this user delegate and that the agent’s capability allowlist names. If none qualifies, no session is created and the call raises ConnectConfigError with code no_eligible_grants (HTTP 409). details["details"]["reason"] names the cause:

reasonCauseWho fixes it
no_grantThe user holds no active grant for a secret created from this template.The application’s administrator issues the user a grant. The user cannot issue one.
grant_expiredEvery grant the user holds for it has passed its own expiry.The application’s administrator renews the grant or issues a new one. The user cannot renew it.
delegation_depth_exceededThe secret’s max_delegation_depth leaves no room for a delegation below the user’s grant.An administrator raises the depth ceiling.
group_source_not_allowedThe user holds the credential only through a group, and the secret does not allow delegation from group grants.An administrator allows group-source delegation on the secret, or gives the user a direct grant.
agent_not_allowlistedThe agent’s capability allowlist does not contain the secret’s slug.An administrator adds the secret’s exact slug to the agent’s managed_secret_slugs allowlist.

A grant the agent’s allowlist does not name carries agent_not_allowlisted whatever its delegation policy says, because no policy change would let the agent use it; a policy reason is reported only for a grant the agent could use once that reason is fixed. When the user’s grants are blocked for different reasons, reason is the first of grant_expired, delegation_depth_exceeded, group_source_not_allowed and agent_not_allowlisted that any of them carries. This check applies only to a key bound to no managed agent: an app key, including an Agent built with App.get_agent(), or an agent-typed alter_ak_… key minted under API Keys. A managed agent’s own key never receives no_eligible_grants (see the Agent method).

The user signs in to approve. The session token does not approve anything on its own, because the application that requested the delegation holds it too. The hosted page first asks the user to sign in through the application’s identity provider — usually a silent redirect when the user already has a session there — and only the browser that completed that sign-in, as the user user_token names, can list the user’s credentials and approve. Cancelling needs no sign-in, so the user can always decline. A failed sign-in never ends the request: if the user signs in with a different account, cancels at the identity provider, or the sign-in cannot be verified, nothing is granted, the page says why, and the user can sign in again (after a wrong account, Use a different account forces a fresh sign-in) or cancel. The identity provider therefore needs its sign-in client configured: the wallet client ID and secret, with the SDK callback URL registered (see Approving Connect delegations). Without it, this call raises ConnectConfigError with code idp_signin_not_configured (HTTP 409, details["retryable"] is False), and details["details"]["reason"] says what to change: missing_client_id or missing_client_secret — add the sign-in client ID and secret to the identity provider; issuer_cannot_sign_in — use an issuer that supports OpenID Connect sign-in (for WorkOS, the AuthKit issuer https://<slug>.authkit.app). A wrong delegated_agent_id is reported first: agent_not_found is raised even when the identity provider is not ready.

The page lists only the credentials the user may delegate to this agent. Approval checks the chosen credential again, including the agent’s allowlist, so an allowlist narrowed after the page loaded refuses the approval on the page (agent_allowlist_exceeded) and creates nothing. A credential that expired, was revoked or is being deleted after the page loaded, or that the user held through a group they have since left, is refused on the page as grant_not_found, and onward sharing the credential’s policy no longer allows as grant_not_delegable (the user can approve again without it). Neither creates anything.

Open connect_url in the system browser (a popup or a redirect on the web; the system browser, not an embedded WebView, in a native app): identity providers refuse or cannot share their sign-in session with embedded WebViews, and an app that controls the WebView could drive the approval itself.

What the application learns. The request’s outcome — approved or cancelled — or an error code: session_expired (the request expired while the page was open), idp_signin_not_configured (the identity provider lost its sign-in client after the session was created, so nobody can approve until an administrator fixes it; in the popup flow the user can still cancel), or the code of an approval the final write refused, which ends the request: grant_not_found (the selected access was revoked or expired meanwhile), agent_not_found, agent_allowlist_exceeded, group_delegation_not_allowed, delegation_depth_exceeded, grant_not_delegable, policy_violation, delegation_ttl_out_of_bounds, delegation_conflict, invalid_scope_constraint, delegation_refused, invalid_policy_draft or approver_not_found (a usage limit the user added could not be applied), or internal_server_error. They arrive as {"type": "alter:managed-secret-connect:error", "error_code": "<code>"} in the popup postMessage, or error=<code> on return_url in the redirect flow. Nothing was delegated; start a new session. A refusal the user can still answer — the selected access stopped being one this agent may use before the approval started, or the user must sign in again — sends nothing: the page asks the user to pick again or sign in, and the request stays open. A sign-in is never an outcome — no approver_* or binding_cookie_mismatch code is ever sent to the application — and a request that was already answered (for example in another tab) sends nothing more. session_expired is delivered only while the page still holds the live request: once the session is gone the page no longer knows the opener’s origin or the return_url, so a link opened after expiry shows the user an ended page and sends nothing. Treat a flow that ends with no message and no return as not approved, and start a new session.

Returns: ManagedSecretConnectSession. The user opens connect_url; on Approve the backend returns a delegation_id (in the postMessage payload or return_url query) for the agent’s delegation, which the agent then passes as the grant_id to proxy_request(). A delegation is proxy-only: request() raises RestrictedGrantRequiresProxyError for it. The approval carries updated: True when the user already had a live delegation to this agent from the chosen credential and the approval renewed it in place (same delegation_id), False when it created a new one. It also carries delegable: the delegation’s onward-sharing setting as stored after the approval, which is the user’s choice or the existing delegation’s kept setting (a new delegation the user was not asked about is False). False means the agent cannot share this access onward. True is the setting, not a promise: the secret’s delegation policy still decides every onward delegation, so an existing delegation can keep True after the policy stopped allowing onward sharing (the ceiling was lowered, or no hops are left), and agent.delegate() from it is then refused until an administrator allows onward sharing again. The approved postMessage is {"type": "alter:managed-secret-connect:approved", "delegation_id": ..., "agent_id": ..., "template_slug": ..., "expires_at": ..., "updated": ..., "delegable": ...}. On the return_url flow the query carries delegation_id, expires_at, updated and delegable (updated and delegable as true or false) on approval, denied=true on denial, or error=<code> (one of the two codes above) when no answer came — after consuming them, remove whichever landed (delegation_id, expires_at, updated, delegable, denied or error) from the address bar (history.replaceState) so a refresh or a shared link cannot re-trigger the return handling against a spent session.

Raises: AlterValueError (None, an empty string or a malformed value for template_slug, delegated_agent_id or user_token, or a non-boolean allow_user_policy_rules / delegable, before any request), TypeError (a required argument omitted), ConnectConfigError (no_eligible_grants or idp_signin_not_configured, above), ServerFeatureUnsupportedError (delegable=True against a backend that predates it, so the session does not echo it as True), AgentNotFoundError (delegated_agent_id names no active agent of the app; for an Agent from App.get_agent() whose agent is no longer active, details["error"] == "caller_agent_unresolved"), BackendError (invalid_origin, an unknown template, a user token that fails validation, identity synchronization that could not complete, or a malformed response, including a delegable_requested of True when delegable was not sent), AlterSDKError (client closed), NetworkError, TimeoutError. When user_token verifies but its user cannot be synchronized, the BackendError is split by status_code, as on connect(): 503 (details["error"] == "service_unavailable") is transient — retry after details["retry_after_seconds"] — while 409 (identity_provider_inactive: the identity provider is disabled; identity_group_name_ambiguous: two of its groups share the display name the token claims) is a condition an administrator must repair, carries no retry hint, and does not clear on retry.

from alter_sdk import ConnectConfigError
try:
session = await app.create_managed_secret_connect_session(
template_slug="provider-api-key",
delegated_agent_id=agent_id,
user_token=user_jwt,
requested_ttl_seconds=7 * 24 * 3600,
delegable=True, # ask the user to let the agent share it onward
)
except ConnectConfigError as e:
if e.details.get("error") == "no_eligible_grants":
# Nothing the user holds can be delegated to this agent yet.
reason = e.details["details"]["reason"] # e.g. "no_grant"
return {"error": "no_eligible_grants", "reason": reason}
else:
raise
return {"connect_url": session.connect_url}

Trigger IDP login for an end user via the browser. Opens the app’s configured IDP login page, polls for completion, and returns the user’s JWT.

async def authenticate(*, timeout: float = 300.0) -> AuthResult
ParameterTypeDefaultDescription
timeoutfloat300.0Max seconds to wait for the user to complete login.

Returns: AuthResult with user_token (JWT) and user_info.

Raises: BackendError with details["error"] no_idp_configured (HTTP 400 — the application has no identity provider) or no_wallet_client (HTTP 400 — the identity provider verifies JWTs but has no sign-in client, so sign users in with the identity provider’s own SDK and pass the token to verify_user_token() instead; see Sign-in modes), BackendError with details["error"] == "idp_signin_client_approvals_only" (HTTP 409 — the identity provider’s sign-in client is registered for approvals only, so it cannot sign users in here), AlterSDKError (the client is closed, the identity provider returned a terminal error, or the sign-in session expired), NetworkError, or ConnectTimeoutError (user didn’t complete in time; details["reason"] is "poll_deadline_elapsed").

authenticate() is on App only — agents do not initiate user logins.

result = await app.authenticate(timeout=180.0)
# `result.user_token` is a JWT — keep it in memory; never log or print it.
email = result.user_info.get("email")

The split, headless counterpart to authenticate(). Mints a sign-in session and returns the IDP auth_url without opening a browser or mutating the instance. Hand auth_url to a user on any channel (a chat message, an MCP client, a printed link), then poll with poll_auth_session(). Because it installs no user_token_getter, it is safe on a shared App that resolves a JWT per request.

async def create_auth_session() -> AuthSession

Returns: AuthSession with session_token, auth_url, expires_in, and expires_at. session_token is persistable: a worker can store it, poll in the background, and resume after a restart.

Raises: BackendError with details["error"] no_idp_configured (HTTP 400 — the application has no identity provider) or no_wallet_client (HTTP 400 — the identity provider verifies JWTs but has no sign-in client, so sign users in with the identity provider’s own SDK and pass the token to verify_user_token() instead; see Sign-in modes), AlterSDKError (client closed), BackendError (malformed response, or HTTP 409 idp_signin_client_approvals_only when the identity provider’s sign-in client is registered for approvals only), NetworkError. Requires the idp_users:write scope. App-only — agents do not start user logins.

auth_session = await app.create_auth_session()
# Send `auth_session.auth_url` to the user; never log it (it carries the session token).
return {"sign_in_url": auth_session.auth_url, "session": auth_session.session_token}

The polling half of the link-based flow. Blocks until the user finishes IDP login, then returns their JWT. Like create_auth_session(), it installs no user_token_getter — the caller decides what to do with the token.

async def poll_auth_session(
session_token: str,
*,
timeout: float = 300.0,
poll_interval: float = 2.0,
) -> AuthResult
ParameterTypeDefaultDescription
session_tokenstr—Token from create_auth_session().
timeoutfloat300.0Max seconds to wait. The TS SDK’s equivalent takes milliseconds.
poll_intervalfloat2.0Seconds between polls.

Transient network blips and non-200 responses are retried until the deadline; only a terminal IDP error, an expired session, or the timeout ends the loop.

Returns: AuthResult with user_token (JWT) and user_info.

Raises: AlterValueError (blank session_token), ConnectTimeoutError (deadline; details["reason"] is "poll_deadline_elapsed"), AlterSDKError (closed, terminal IDP error / expired session). A permanent backend failure surfaces as its typed subclass (GrantNotFoundError, PolicyViolationError, ReAuthRequiredError, BackendError). An identity-synchronization refusal is surfaced as BackendError rather than a generic sign-in failure, split by status_code: 503 is transient identity-provider sync unavailability (retry later; details["retry_after_seconds"] carries a backoff hint when present), while 409 means the identity provider is inactive or a group name is ambiguous — conditions an app operator must repair, so retrying does not help. details["error_code"] names the terminal. Requires the idp_users:read scope.

auth_session = await app.create_auth_session()
result = await app.poll_auth_session(auth_session.session_token, timeout=600.0)

Paginated list of grants visible to the calling principal. The unified endpoint returns a discriminated-union result — each item is either an OAuthGrantItem (grant_kind="oauth") or a ManagedSecretGrantItem (grant_kind="managed_secret").

async def list_grants(
*,
provider_id: str | None = None,
status: str | None = None,
account: str | None = None,
label: str | None = None,
end_user_token: str | None = None,
limit: int = 100,
offset: int = 0,
) -> UnifiedGrantListResult
ParameterTypeDefaultDescription
provider_idstr | NoneNoneFilter to one provider. OAuth: a provider id (e.g. "provider-id"). Managed secret: the per-secret slug (e.g. "service-production"), unique per app.
statusstr | NoneNoneFilter by grant status (e.g. "active", "expired", "revoked").
accountstr | NoneNoneFilter by account_identifier (multi-account disambiguation).
labelstr | NoneNoneFilter to one sibling-grant label (multi-grant model; applies to both OAuth and managed-secret labels).
end_user_tokenstr | NoneNoneScope the list to one end user by their JWT — their own grants plus group grants they are a live member of. Takes precedence over a configured user_token_getter; an invalid token is rejected with 401, never a silent fall-through to app scope.
limitint100Page size, 1-1000.
offsetint0Page offset.

Scope. With no end-user scoping, App.list_grants returns every grant the app owns — both OAuth and managed-secret — across every principal kind (user, group, system, agent). Construct the App with a user_token_getter, or pass end_user_token, to narrow the list to one end user: their own grants plus any group grants they are a live member of. All filters are composable and AND-ed; each only narrows the result.

from alter_sdk import App
app = App(api_key="alter_rk_…")
page = await app.list_grants(provider_id="provider-id", limit=50)
for item in page.grants:
if item.grant_kind == "oauth":
print(item.grant_id, item.account_identifier, item.delegated_agent_ids)
async def list_grants(
*,
provider_id: str | None = None,
status: str | None = None,
account: str | None = None,
label: str | None = None,
end_user_token: str | None = None,
limit: int = 100,
offset: int = 0,
) -> UnifiedGrantListResult
ParameterTypeDefaultDescription
provider_idstr | NoneNoneFilter to one provider. OAuth: a provider id (e.g. "provider-id"). Managed secret: the per-secret slug (e.g. "service-production"), unique per app.
statusstr | NoneNoneFilter by grant status (e.g. "active", "expired", "revoked").
accountstr | NoneNoneFilter by account_identifier.
labelstr | NoneNoneFilter to one sibling-grant label (multi-grant model; applies to both OAuth and managed-secret labels).
end_user_tokenstr | NoneNoneScope the agent’s delegated OAuth grants to one end user by their JWT. Agent-owned managed-secret grants have no end user, so they are unaffected.
limitint100Page size, 1-1000.
offsetint0Page offset.

Scope. Returns only the grants the calling agent can reach: OAuth grants delegated to it, plus managed-secret grants it owns or that are delegated to it. The access_via field on each item marks ownership versus delegation (oauth_delegation / ms_delegation). Everything else is invisible — grants belonging to other agents, grants of users who did not delegate to this agent, and generic system grants. All filters are composable and AND-ed.

page = await agent.list_grants()
for item in page.grants:
if item.grant_kind == "oauth":
print("OAuth grant:", item.provider_id, item.account_identifier)
else:
print("Managed secret:", item.managed_secret_slug, item.label)

Raises (both): AlterValueError on bad pagination input, BackendError on backend / wire-shape failure, ReAuthRequiredError on JWT validation failures.

App only. Mint a sibling grant on an existing active grant’s credential without another authorization flow. The sibling copies the source grant’s principal binding, but not its delegations, and can only tighten policy.

async def mint_grant(
source_grant_id: str,
*,
label: str,
grant_policy: GrantPolicyInput | dict[str, Any] | None = None,
grant_tags: list[str] | None = None,
) -> GrantInfo
ParameterTypeDefaultDescription
source_grant_idstr—Active grant owned by the application. Its credential and principal binding become the sibling’s base.
labelstr—Required sibling address. Must be unique among active grants on the credential; identity mode resolves by provider + label.
grant_policyGrantPolicyInput | dict[str, Any] | NoneNoneOptional per-grant policy. GrantPolicyInput covers TTL fields; a dict can carry other supported snake_case policy fields. Backend validation rejects invalid or widening policy.
grant_tagslist[str] | NoneNoneOptional non-empty tag strings stored on the sibling.

Returns: GrantInfo for the new sibling, including label, credential_id, and grant_policy.

Raises: AlterValueError for malformed inputs, GrantNotFoundError when the source is missing, inactive, or not owned by the application, SiblingLabelConflictError when the label is already active on the credential, PolicyViolationError when an agent principal attempts to mint (error agents_cannot_mint) or when the source grant carries grant-level policy rules (error mint_source_has_grant_rules: a sibling does not inherit them, so mint from a grant without grant-level rules, or author the rules on the new grant), and BackendError for policy, quota, or other backend failures.

sibling = await app.mint_grant(
source_grant_id,
label="read-only",
grant_policy={"expires_at": "2026-12-31T00:00:00Z"},
grant_tags=["reporting"],
)
print(sibling.grant_id, sibling.label, sibling.credential_id)

App only. Permanently revokes the specified grant. Tokens are deleted from the vault and the grant status flips to "revoked" — the user must re-authorize to restore access.

async def revoke_grant(
grant_id: str,
*,
reason: str | None = None,
) -> RevokeGrantResult
ParameterTypeDefaultDescription
grant_idstr—Grant to revoke. Positional.
reasonstr | NoneNoneOptional reason recorded in the audit log.

Returns: RevokeGrantResult.

Raises: AlterSDKError (closed), GrantNotFoundError, NetworkError, TimeoutError, BackendError.

result = await app.revoke_grant(grant_id, reason="key_rotation")
print(result.success, result.revoked_at)

Revoke an agent’s delegation on an OAuth grant. The OAuth grant itself stays active; only the named agent’s access path is removed.

Operator path — both grant_id and agent_id are required.

async def revoke_delegation(grant_id: str, agent_id: str) -> None
ParameterTypeDefaultDescription
grant_idstr—OAuth grant whose delegation should be revoked.
agent_idstr—Agent UUID whose delegation should be revoked.

Raises: AlterValueError (non-canonical UUID inputs), BackendError (use_self_revoke_path if the caller is an agent).

await app.revoke_delegation(grant_id="…", agent_id="…")

Self-revoke — the agent revokes its own delegation. Idempotent.

async def revoke_delegation(grant_id: str) -> None
ParameterTypeDefaultDescription
grant_idstr—OAuth grant whose delegation should be revoked.

Raises: AlterValueError (non-canonical grant UUID), BackendError (agent_key_required), NetworkError, TimeoutError.

await agent.revoke_delegation(grant_id="…")

Agent only. Onward (agent → agent) delegation — the second hop. An agent that already holds a grant mints a child of it for another agent, without the credential owner consenting again.

async def delegate(
grant_id: str,
to_agent_id: str,
*,
delegable: bool | None = None,
scope_constraint: list[str] | None = None,
ttl_seconds: int | None = None,
) -> DelegationResult
ParameterTypeDefaultDescription
grant_idstr—A grant the calling agent already holds. Positional.
to_agent_idstr—The agent that should receive the child grant. Positional.
delegablebool | NoneNoneWhether the recipient may re-delegate the child onward. True or False is stored as given. Whatever delegable says, the held grant must be delegable and the credential’s operator ceiling (a managed secret’s delegation policy, or the OAuth provider’s agent sharing setting) must allow onward delegation; otherwise the call raises GrantNotDelegableError, renewals included. True also needs room under the ceiling’s depth for the child to delegate once more, and raises GrantNotDelegableError when there is none; False never triggers that depth check. Omit delegable to keep the existing delegation’s setting: re-delegating to an agent that already holds a child of this grant keeps its current value, and a new child is created as a leaf (onward delegation is opt-in per hop). The SDK sends the field only when it is set.
scope_constraintlist[str] | NoneNoneNarrow the child to a subset of the parent’s provider scopes. Provide a non-empty list of at most 100 concrete scope strings, each ≤ 512 characters and a valid RFC 6749 scope-token (printable ASCII — no spaces, " or \; one atom per scope); "*", empty strings, and an empty list are rejected (a wildcard never narrows) — pass None (or omit) for full scope. Every atom must be within the parent grant’s effective scopes (its credential scopes, narrowed by any constraint already on its chain; a broader scope that subsumes the atom qualifies) — an atom outside that set is rejected with HTTP 422 invalid_scope_constraint, and an existing child delegation is left unchanged. Managed-secret grants carry no provider scopes, so a constraint on a managed-secret delegation is always rejected — narrowing applies to OAuth delegations. A scope-narrowed grant is proxy-only — call it with proxy_request().
ttl_secondsint | NoneNoneShorten the child’s lifetime. Never extends past the parent; the backend clamps the child’s expiry to the parent’s.

The held grant must have been created as delegable: the user chose that on the consent screen (or in the wallet), within the credential’s operator ceiling, which is a managed secret’s delegation policy or the OAuth provider’s agent sharing setting. The ceiling is checked again on every onward call, so a ceiling turned off after the grant was created refuses further delegation. A child can only narrow — fewer scopes, a shorter lifetime — never widen.

Returns: DelegationResult — the freshly minted child grant (grant_id, parent_grant_id, depth, delegable, status, expires_at).

Raises: GrantNotDelegableError (the held grant has no consenting user, is not delegable, or the credential’s operator ceiling is off — a managed secret’s delegation policy or the OAuth provider’s agent sharing setting — or delegable=True for a child the depth ceiling leaves no further hop — HTTP 403 grant_not_delegable; its detail_reason names which: no_consenting_user, holder_not_delegable, ceiling_off or no_onward_hop), GrantNotFoundError (the caller does not hold grant_id), AgentNotFoundError (to_agent_id names no active agent of the app — HTTP 404 agent_not_found), AlterValueError (empty inputs), BackendError (HTTP 422 delegation_depth_exceeded — the child would sit deeper than the ceiling allows), BackendError (HTTP 422 invalid_scope_constraint — a scope_constraint atom outside the parent’s effective scopes; the existing child, if any, is unchanged).

from alter_sdk import Agent, GrantNotDelegableError
agent = Agent(api_key="alter_key_…") # the agent's own key, from agents.create
try:
child = await agent.delegate(
"…", # a grant this agent holds
"…", # the recipient agent
scope_constraint=["chat:write"],
ttl_seconds=3600,
)
print(child.grant_id, child.parent_grant_id, child.depth)
except GrantNotDelegableError as e:
# e.detail_reason says why: "ceiling_off" — the operator turns sharing on;
# "holder_not_delegable" — ask the user again with delegable=True.
print("cannot share onward:", e.detail_reason)

list_grants() returns each grant with parent_grant_id (the grant it was minted under, None for a root) and depth (its distance from the root), so the full delegation chain reconstructs from the flat list.

App only. Create a managed-secret grant bound to a user, group, or system principal. The exported Principal model union also includes AgentPrincipal, but this method rejects that variant; operator tooling creates agent-bound grants.

async def create_managed_secret_grant(
managed_secret_id: str,
*,
principal: Principal,
grant_policy: GrantPolicyInput | dict[str, Any] | None = None,
) -> CreateGrantResult
ParameterTypeDefaultDescription
managed_secret_idstr—Managed-secret id. Positional.
principalPrincipal—Discriminated principal model (see below).
grant_policyGrantPolicyInput | dict[str, Any] | NoneNoneOptional grant policy (e.g. {"expires_at": "2026-12-31T00:00:00Z"}). Validated against the input keyset.

Returns: CreateGrantResult.

Principal is a discriminated union over four shapes:

TypeDiscriminator (type)Required fields
UserPrincipal"user"user_token, label
GroupPrincipal"group"external_group_id, idp_id, label
SystemPrincipal"system"(label optional)
AgentPrincipal"agent"Not accepted by this SDK method. Create agent-bound managed-secret grants via the Developer Portal flow instead.
from alter_sdk import App
from alter_sdk.models import UserPrincipal, GroupPrincipal, SystemPrincipal
app = App(api_key="alter_rk_…")
# User-bound
result = await app.create_managed_secret_grant(
managed_secret_id="ms_service_prod",
principal=UserPrincipal(user_token=user_jwt, label="Production service"),
)
# Group-bound (requires an identity provider that supports group grants,
# with its webhook integration enabled — otherwise the backend rejects
# the call with a 422 group_principal_unsupported_idp error)
result = await app.create_managed_secret_grant(
managed_secret_id="ms_monitoring",
principal=GroupPrincipal(
idp_id="11111111-2222-3333-4444-555555555555",
external_group_id="oncall-team",
label="On-call rotation",
),
)
# System (server-to-server)
result = await app.create_managed_secret_grant(
managed_secret_id="ms_internal_docs",
principal=SystemPrincipal(label="weekly_report_cron"),
)
# Agent-bound principals are not accepted by this SDK call.
# Create agent-bound managed-secret grants via the Developer Portal.

The dict shorthand also works:

result = await app.create_managed_secret_grant(
managed_secret_id="ms_service",
principal={"type": "user", "user_token": user_jwt, "label": "Alice"},
)

When a grant has requires_approval configured, proxy_request() returns a PendingApproval instead of executing the call. await_approval() blocks until the approval reaches a terminal state, then returns the proxied result.

async def await_approval(
approval_id: str,
*,
timeout: float = 300.0,
poll_interval: float = 2.0,
) -> ApprovalResult
ParameterTypeDefaultDescription
approval_idstr—The approval row id (from PendingApproval.approval_id). Positional.
timeoutfloat300.0Local wait timeout in seconds. The approval row may still be pending on the backend after this elapses — the SDK simply gives up waiting.
poll_intervalfloat2.0Fallback cadence in seconds. Each poll asks the server to hold the request open until the status changes, so this rarely paces the loop; it spaces retries after a transient failure, and it paces polling against an older backend that answers every poll immediately.

Returns: ApprovalResult on the executed terminal (the proxied provider response).

Raises:

  • ApprovalDeniedError — approver explicitly denied.
  • ApprovalExpiredError — approval window elapsed before any decision.
  • ApprovalExecutionFailedError — approved but backend’s proxy execution failed (e.g. grant revoked between approval and execution).
  • ApprovalTimeoutError — local wait elapsed before any decision. Re-poll later if the row is still pending.

Transient gateway errors (502, 503, 504) and network failures during the wait are retried silently until the deadline elapses — the approval row is fine on the backend and the next poll typically succeeds.

from alter_sdk.exceptions import ApprovalDeniedError, ApprovalTimeoutError
from alter_sdk.models import PendingApproval
result = await app.proxy_request(
"POST",
"https://api.provider.example/v1/actions",
grant_id=grant_id,
json={"resource_id": "resource_abc"},
)
if isinstance(result, PendingApproval):
try:
final = await app.await_approval(
str(result.approval_id),
timeout=600.0,
poll_interval=3.0,
)
print("Approved:", final.status_code, final.body_json())
except ApprovalDeniedError as e:
print("Denied:", e.message)
except ApprovalTimeoutError:
# row may still be pending — re-poll later
...

Poll an approval row. By default returns the current status snapshot immediately; pass wait to have the server hold the request open until the status changes.

async def get_approval_status(approval_id: str, *, wait: int | None = 0) -> ApprovalStatus
ParameterTypeDefaultDescription
approval_idstr—The approval row id.
waitint | None0Optional seconds to wait for a status change before returning. 0 (the default) or None returns immediately; a non-finite or non-numeric value raises AlterValueError. A positive value asks the server to hold the request open until the status changes or wait elapses, then return the current status — a latency optimization that avoids tight re-polling. The maximum is 25; larger values are rejected with a validation error, raised as BackendError. The response shape is identical either way. Most callers should prefer await_approval(), which manages the wait loop and returns the result.

Returns: ApprovalStatus. Branch on .status or .is_terminal.

status = await app.get_approval_status(approval_id)
if status.is_terminal:
print("Decided:", status.status, "at", status.decided_at)
else:
print("Still", status.status)

ApprovalStatus.status is one of:

ValueTerminalMeaning
"pending"noApprover has not decided.
"approved"noDecision made; backend is about to execute the proxied call.
"executing"noBackend is currently running the proxied call.
"denied"yesApprover declined.
"expired"yesApproval window elapsed.
"executed"yesProxied call completed; has_result=True once the result is ready to fetch.
"failed"yesExecution failed after approval.

await_approval() handles every terminal automatically and raises the matching typed exception (or returns ApprovalResult for executed).

ApprovalResult.body_b64 is base64-encoded. Use the helpers:

  • body_bytes() -> bytes
  • body_text(encoding: str = "utf-8") -> str
  • body_json() -> Any
  • .status_code — HTTP status from the provider
  • .headers — provider response headers (dict)
  • .duration_ms — provider round-trip milliseconds (None when unavailable)
  • .approval_id — the approval row id (None for synchronous, non-HITL proxy_request results)

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.