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 catalogconst catalog = await app.oauthProviders.list();
// Connect flowsconst results = await app.connect({ providers: ["<provider-id>"] });const session = await app.createConnectSession({ allowedProviders: ["<provider-id>"] });const grants = await app.pollConnectSession(session.sessionToken);const recover = await app.createConnectSessionForError(caughtError);const msSess = await app.createManagedSecretConnectSession({ /* ... */ });
// End-user sign-in (IDP login)const auth = await app.authenticate(); // CLI convenienceconst authSession = await app.createAuthSession(); // headless / remote userconst result = await app.pollAuthSession(authSession.sessionToken);
// Grantsconst page = await app.listGrants({ limit: 50 });await app.revokeGrant(grantId, { reason: "rotation" });const grant = await app.createManagedSecretGrant("ms_…", { principal });const sibling = await app.mintGrant(grantId, { label: "read-only" });
// Delegationsawait app.revokeDelegation(grantId, agentId); // operatorawait agent.revokeDelegation(grantId); // agent self-revoke
// Approvalsconst final = await app.awaitApproval(approvalId, { timeoutMs: 600_000 });const status = await app.getApprovalStatus(approvalId);oauthProviders.list
Section titled “oauthProviders.list”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 list(options?: { forceRefresh?: boolean }): Promise<OAuthProviderCatalog>| Option | Type | Default | Description |
|---|---|---|---|
forceRefresh | boolean | false | Bypass 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.oauthProviders / agent.oauthProviders.
Returns OAuthProviderCatalog — a providers record keyed by id, plus getDefaultScopes(provider) / getRequiredScopes(provider) helpers. Throws BackendError on a malformed catalog response. Requires the providers:read scope.
const catalog = await app.oauthProviders.list();const provider = catalog.providers["<providerId>"];console.log(provider.displayName, provider.defaultScopes);
// Pre-fill a Connect flow with the provider's default scopes.const scopes = catalog.getDefaultScopes("<providerId>");createConnectSession
Section titled “createConnectSession”Mint a Connect session URL. The application then surfaces the URL to the user — popup, redirect, chat message, etc. In a native app, open it 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 createConnectSession( options?: CreateConnectSessionOptions,): Promise<ConnectSession>| Option | Type | Description |
|---|---|---|
allowedProviders | string[] | Restrict to specific provider IDs (for example, ["<provider-id>"]). When omitted the user can pick any configured provider. 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 singleProvider 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). The end user still confirms the connection on the way back; to skip that as well, see direct. An empty array or a blank entry throws AlterValueError before any request is sent (omit the option to offer every configured provider). Entries are matched case-insensitively after trimming, so ["GitHub", "github"] counts as one provider. |
direct | boolean | Opt 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 allowedProviders and throws 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 allowUserPolicyRules 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 grantPolicy bounds still apply. |
returnUrl | string | URL the browser redirects to after consent (mobile / single-page flows). |
allowedOrigin | string | Origin allowed for the postMessage completion event (popup flow). |
metadata | { ipAddress?: string; userAgent?: string } | Forwarded to the audit row. |
grantPolicy | GrantPolicyInput | Optional absolute expiry and TTL bounds the user picks from on the consent screen. TTL fields are seconds. |
requiredScopes | Record<string, string[]> | Per-provider scope ceiling. The OAuth URL is built with these scopes (must be a subset of the application’s Dev Portal configuration). |
agent | string | Agent UUID or managed-agent name. When set, on approval the SDK returns a delegation grant that the agent uses as grantId on request(), 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 — pollConnectSession 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. |
requestedGrant | RequestedGrant | Sibling-grant request (multi-grant model). The grant created by this session carries the required label (the sibling address — resolution key is provider + label) and, when set, the method/endpoint restrictions, which make it proxy-only. Unknown keys throw AlterValueError at the SDK boundary. |
repairGrantId | string | Reauthorize this active grant in place. Requires exactly one allowedProviders entry; cannot be combined with agent, requestedGrant, or switchAccount. Normally supplied automatically by createConnectSessionForError() after CredentialRevokedError. |
delegable | boolean | Only meaningful with agent. When true, the resulting (first-hop) grant may itself be re-delegated by the recipient agent via agent.delegate(). Onward delegation is opt-in at every hop — leave it false (the default) for a leaf grant the recipient cannot pass on. |
scopeConstraint | string[] | Requires 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). Omit for full credential scope. Provide a non-empty array 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 array are rejected. 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.scopeConstraintWarnings 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 proxyRequest(). |
userToken | string | Per-call user JWT. Overrides userTokenGetter. 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 grantId 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 userTokenGetter is configured (a session with neither is rejected — a delegation needs a consenting user). |
switchAccount | boolean | Force 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. Default false (routine reconnects keep the one-click fast path). Must be a boolean — a non-boolean throws AlterValueError at the SDK boundary. |
allowUserPolicyRules | boolean | Whether 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 throws AlterValueError at the SDK boundary. |
RequestedGrant and RequestedGrantRestrictions are exported from the package root:
interface RequestedGrant { label: string; restrictions?: RequestedGrantRestrictions | null;}
interface RequestedGrantRestrictions { allowedMethods?: string[]; allowedEndpoints?: string[];}Returns ConnectSession — connectUrl is what the user opens; sessionToken is what you pass to pollConnectSession().
Throws:
AlterValueError— a boundary guard named in the option table: an empty or malformedallowedProvidersor a blank entry in it, a non-booleanswitchAccount/allowUserPolicyRules/direct,directwithout exactly one provider, a malformedgrantPolicy/requestedGrantkeyset, or an invalidscopeConstraintatom. Thrown before any request is sent.ConnectConfigError— the typedmanaged_oauth_scope_approval_requiredterminal (HTTP 409): the requested scopes exceed what the Alter-managed OAuth client is approved for. It is not aBackendError, so a handler that branches only onBackendErrorleaves 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 isretryable: falseand every fresh session fails identically until the approval changes.BackendError— a backend rejection, includingscope_not_allowedwhen a requested scope is outside the app’s configured allowlist and HTTP 422invalid_scope_constraintwhen thescopeConstraintis invalid for every allowed provider.AlterSDKError— the SDK client is closed.NetworkError/TimeoutError— transport failure.
On this call the error’s details is the full 409 body, which is the only way to tell an operator which scopes need approving:
import { ConnectConfigError } from "@alter-ai/alter-sdk";
try { const session = await app.createConnectSession({ allowedProviders: ["<provider-id>"] });} catch (error) { if ( error instanceof ConnectConfigError && error.details.error === "managed_oauth_scope_approval_required" ) { // `details` is `Record<string, unknown>` — narrow before reading. const blocked = error.details.details as { provider_id: string; unapproved_scopes: string[]; }; const remediation = error.details.remediation as { action: string; description: string; }; console.log(blocked.provider_id, blocked.unapproved_scopes); console.log(remediation.action, remediation.description); // error.details.retryable === false — do not retry. } throw error;}const session = await app.createConnectSession({ allowedProviders: ["<provider-id>"], allowedOrigin: "https://app.example.com", grantPolicy: { maxTtlSeconds: 30 * 24 * 3600, defaultTtlSeconds: 7 * 24 * 3600 },});
window.open(session.connectUrl, "alter-connect", "popup");const grants = await app.pollConnectSession(session.sessionToken);createManagedSecretConnectSession
Section titled “createManagedSecretConnectSession”Mint a Connect session for the user → agent delegation flow on a managed secret. The user consents to the named agent using the user’s existing managed-secret access.
async createManagedSecretConnectSession( options: CreateManagedSecretConnectSessionOptions,): Promise<ManagedSecretConnectSession>| Option | Type | Description |
|---|---|---|
templateSlug | string | Canonical managed-secret template slug in kebab-case. Required. |
delegatedAgentId | string | UUID of the agent being authorized. Required. |
userToken | string | IDP JWT identifying the consenting user. Required. |
requestedTtlSeconds | number | Optional positive integer caller-suggested TTL in seconds. Capped by the per-secret policy, source credential expiry, and 90 days; omit it to use the selected secret’s policy and default cap. |
allowedOrigin | string | Origin allowed for the postMessage event. |
returnUrl | string | URL for the mobile redirect flow. |
allowUserPolicyRules | boolean | Whether the consenting user may add self-scoped, narrowing-only usage limits in the hosted approval flow. Defaults to true; set false to hide the policy step. The rules are attached atomically to the delegated managed-secret grant, and approval fails if they cannot be applied. A non-boolean throws AlterValueError before any request. |
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 delegatedAgentName option (or delegated_agent_name) throws AlterValueError before any request.
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 userToken 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 throws 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).
Open connectUrl 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, before any answer, one of two error codes: session_expired (the request expired while the page was open) or 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). They arrive as {"type": "alter:managed-secret-connect:error", "error_code": "<code>"} in the popup postMessage, or error=<code> on returnUrl in the redirect flow. 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 returnUrl, 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 with the session URL and polling metadata; it does not contain a delegation ID. The user opens connectUrl. After approval, the hosted flow delivers delegation_id in the postMessage payload or returnUrl query, and the agent passes that value as grantId on request(). On the returnUrl flow the query carries delegation_id and expires_at 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, 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.
App-only method. The supplied user JWT is validated and the normal grant/policy checks apply.
pollConnectSession
Section titled “pollConnectSession”Block until a session reaches a terminal state. Use this when application code minted the session itself and needs to wait for completion.
async pollConnectSession( sessionToken: string, options?: { timeoutMs?: number; pollIntervalMs?: number; onEvent?: (event: { type: "provider_declined"; provider_id: string; error_code: string; declined_at: string | null; }) => void; },): Promise<ConnectResult[]>| Option | Type | Default | Description |
|---|---|---|---|
timeoutMs | number | 300_000 (5 min) | Positive, finite maximum time to wait. Milliseconds. An explicit value is honored; it does not shorten the server session. Elapsed time uses a monotonic clock. |
pollIntervalMs | number | 2_000 | Positive, finite time between polls. Milliseconds. |
onEvent | function | — | Synchronous callback fired once per provider the user declines while the session remains recoverable (a multi-provider picker where another provider can still be chosen). declined_at is an ISO-8601 timestamp string, or null when the service omits it. Exceptions it throws are logged and never interrupt the poll; a returned Promise is not awaited, but its rejection is caught and logged. |
Returns one ConnectResult per provider the user completed. On partial completion, every result carries the same typed failedGrants array (providerId, reason, message) for providers whose grants were revoked because the selected usage limits could not be applied. The array is empty for full success; unknown reason strings are preserved.
Throws:
AlterValueError— blank/non-string token or non-finite/non-positive polling control; rejected before any request.ConnectTimeoutError— the local deadline elapsed (details.reason === "poll_deadline_elapsed"), a session observed pending expired before Alter received a callback ("session_expired"), or the whole budget was spent backing off a throttled poll ("rate_limited", withdetails.retryAftercarrying the last hint the service gave).PolicyViolationError—policyError === "ip_not_allowed", when the API key is restricted to an IP range that the polling host is outside of.ConnectDeniedError— the user clicked Deny.ConnectConfigError— provider configuration issue (invalid redirect URI, unknown client, etc.), including typedprovider_configuration_unavailable,shared_dev_credential_unavailable, andmanaged_oauth_scope_approval_requiredterminals when the 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.ConnectFlowError— other failure, including an unavailable/expired token on the first poll (the API deliberately does not reveal whether a session existed) or a completed flow where every authorized grant was revoked because the selected usage limits could not be applied. In the latter case,error.details.failed_grantscontains the typed failures in snake_case wire form.BackendError— an identity-synchronization refusal, split bystatusCode:503is transient identity-provider sync unavailability (retry later;details.retry_after_secondscarries a backoff hint when present), while409means the identity provider is inactive or a group name is ambiguous — conditions an app operator must repair, so retrying does not help.details.error_codenames the terminal. Also thrown for malformed terminal error fields or another malformed backend response.
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: onEvent 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.
createConnectSessionForError
Section titled “createConnectSessionForError”Recover from a typed credential failure. Pass the caught exception and the method extracts the provider, the delegated agent (when present), and builds a re-authorization session. For CredentialRevokedError, it also targets the error’s grantId, so success preserves that grant and returns operation: "reauth" instead of creating a duplicate.
async createConnectSessionForError( error: NoDelegatedGrantError | GrantNotFoundError | CredentialRevokedError, options?: { allowedOrigin?: string; returnUrl?: string; metadata?: { ipAddress?: string; userAgent?: string }; grantPolicy?: GrantPolicyInput; requiredScopes?: Record<string, string[]>; userToken?: string; },): Promise<ConnectSession>try { await app.request("GET", url, { provider: "<provider-id>" });} catch (error) { if (error instanceof NoDelegatedGrantError) { const session = await app.createConnectSessionForError(error, { allowedOrigin: "https://app.example.com", }); redirectUser(session.connectUrl); const results = await app.pollConnectSession(session.sessionToken); // Retry the original call. } else { // Re-throw anything you don't explicitly recover from — never silently // swallow an unexpected error (e.g. a network failure or provider error). throw error; }}Throws:
AlterValueError— the typed error has noproviderIdcontext (rare — only happens when the original failure was direct-mode against a stalegrantId), or aNoDelegatedGrantErrorcarries noagentId.ConnectConfigError— the recovery session could not be minted because the Alter-managed OAuth client is not approved for the scopes this app requests (managed_oauth_scope_approval_required). 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. SeecreateConnectSessionfor thedetailspayload naming the unapproved scopes.- Everything else
createConnectSessionthrows — this method forwards to it. Theelse { throw error; }branch in the example above re-throws these, so a handler that only catchesNoDelegatedGrantErrorwill surface aConnectConfigErrorto its own caller.
connect
Section titled “connect”The all-in-one headless flow. Mints a session, opens the user’s default browser, polls until done, and returns the resulting grants.
async connect(options: ConnectOptions): Promise<ConnectResult[]>| Option | Type | Default | Description |
|---|---|---|---|
providers | string[] | — | Restrict to specific providers. An empty array or a blank entry throws AlterValueError before any request is sent. |
timeout | number | 300_000 | Positive, finite maximum wait. Milliseconds. An explicit value is honored; it does not shorten the server session. Elapsed time uses a monotonic clock. |
pollInterval | number | 2_000 | Positive, finite time between polls. Milliseconds. |
openBrowser | boolean | true | When false, prints the URL instead of launching the browser. |
direct | boolean | false | Skip the end user’s confirmation screen, duration choice and in-flow policy step, closing the page on delivery. Requires exactly one entry in providers; throws AlterValueError locally otherwise. Never implied by the provider count. |
grantPolicy | GrantPolicyInput | — | Optional absolute expiry and TTL bounds passed to the Connect UI. TTL fields are seconds. |
allowUserPolicyRules | boolean | true | Whether 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 throws AlterValueError at the SDK boundary. |
const results = await app.connect({ providers: ["<provider-id>"], timeout: 10 * 60_000,});console.log(`Connected ${results.length} provider(s)`);for (const failed of results[0]?.failedGrants ?? []) { console.warn(`Not connected: ${failed.providerId}: ${failed.message}`);}Use this for CLI tools and scripts. For embedded UI flows, prefer createConnectSession() + pollConnectSession() so application code controls the rendering.
The browser launch uses the optional open package when the host application installs it. It is not a declared SDK peer dependency. When it is unavailable, connect() falls back to printing the URL.
Throws:
AlterValueError—grantPolicyis malformed,allowUserPolicyRulesordirectis not a boolean,directis set without exactly one provider, orprovidersis empty, not an array of strings, or has a blank entry.ConnectTimeoutError— Alter received no completion callback before the local deadline or observed session expiry; inspectdetails.reason.ConnectDeniedError— the user clicked Deny and the session had nothing left to try.ConnectConfigError— a provider configuration failure, including the typedprovider_configuration_unavailable,shared_dev_credential_unavailable, andmanaged_oauth_scope_approval_requiredterminals returned when an app cannot start a fresh provider authorization. For managed OAuth approval, contact Alter to approve the requested scopes — or narrow the app’s configured scopes to the approved set — beforeconnect()is called again; it isretryable: falseand every fresh session fails identically until the approval changes.ConnectFlowError— another terminal failure, including when every authorized grant was revoked because selected usage limits could not be applied. The total-failure error carriesdetails.failed_grants; partial failures are returned asConnectResult.failedGrants.BackendError— an identity-synchronization refusal from polling, split bystatusCode:503is transient identity-provider sync unavailability (retry later;details.retry_after_secondscarries a backoff hint when present), while409means the identity provider is inactive or a group name is ambiguous — conditions an app operator must repair, so retrying does not help.details.error_codenames the terminal. Also thrown for malformed backend responses.
authenticate
Section titled “authenticate”Open the application’s configured IDP login page in the user’s default browser, poll until the user authenticates, and return their IDP JWT.
async authenticate(options?: { timeoutMs?: number }): Promise<AuthResult>| Option | Type | Default | Description |
|---|---|---|---|
timeoutMs | number | 300_000 | Maximum wait. Milliseconds. |
Returns AuthResult with userToken and userInfo. Throws BackendError with details.error === "idp_signin_client_approvals_only" (HTTP 409) when the identity provider’s sign-in client is registered for approvals only, so it cannot sign users in here.
App-only method.
createAuthSession
Section titled “createAuthSession”The split, headless counterpart to authenticate(). Mints a sign-in session and returns the IDP authUrl without opening a browser or mutating the instance. Hand authUrl to a user on any channel (a chat message, an MCP client, a printed link), then poll with pollAuthSession(). Because it installs no userTokenGetter, it is safe on a shared App that resolves a JWT per request.
async createAuthSession(): Promise<AuthSession>Returns AuthSession with sessionToken, authUrl, expiresIn, and expiresAt. sessionToken is persistable: a worker can store it, poll in the background, and resume after a restart. Throws AlterSDKError (no IDP configured) or BackendError (malformed response, or HTTP 409 idp_signin_client_approvals_only when the identity provider’s sign-in client is registered for approvals only). Requires the idp_users:write scope. App-only — agents do not start user logins.
const session = await app.createAuthSession();// Send `session.authUrl` to the user; never log it (it carries the session token).return { signInUrl: session.authUrl, session: session.sessionToken };pollAuthSession
Section titled “pollAuthSession”The polling half of the link-based flow. Resolves when the user finishes IDP login, returning their JWT. Like createAuthSession(), it installs no userTokenGetter — the caller decides what to do with the token.
async pollAuthSession( sessionToken: string, options?: { timeoutMs?: number; pollIntervalMs?: number },): Promise<AuthResult>| Option | Type | Default | Description |
|---|---|---|---|
timeoutMs | number | 300_000 | Maximum wait. Milliseconds. The Python SDK’s equivalent takes seconds. |
pollIntervalMs | number | 2_000 | Milliseconds 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 userToken and userInfo. Throws AlterValueError (blank sessionToken), ConnectTimeoutError (deadline; details.reason is "poll_deadline_elapsed"), or the matching typed subclass on a permanent backend failure. An identity-synchronization refusal is surfaced as BackendError rather than a generic sign-in failure, split by statusCode: 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.
const session = await app.createAuthSession();const result = await app.pollAuthSession(session.sessionToken, { timeoutMs: 600_000 });listGrants
Section titled “listGrants”Return the calling principal’s accessible grants, paginated.
async listGrants( options?: AppListGrantsOptions | AgentListGrantsOptions,): Promise<UnifiedGrantListResult>Available on both App and Agent. The backend dispatches by principal kind:
- App — every grant the application owns: OAuth and managed-secret, across all principal kinds (user, group, system, agent). With
userTokenGetterconfigured (orendUserTokenpassed), the list narrows to that end user — their own grants plus group grants they are a live member of. - Agent — only what the agent can reach: OAuth grants delegated to it, plus managed-secret grants it owns or that are delegated to it (
accessViamarksownershipvs delegation). Other agents’ grants, non-delegating users’ grants, and generic system grants are invisible. Mixed result type.
Both AppListGrantsOptions and AgentListGrantsOptions accept the same filters — all AND-ed, each only narrowing the result:
| Option | Type | Default | Description |
|---|---|---|---|
providerId | string | — | Filter to one provider. OAuth: the provider ID. Managed secret: the per-secret slug, unique per app. |
status | string | — | Filter by grant status (e.g. "active", "expired", "revoked"). |
account | string | — | Filter by accountIdentifier (multi-account disambiguation). |
label | string | — | Filter to one sibling-grant label (multi-grant model; applies to both OAuth and managed-secret labels). |
endUserToken | string | — | Scope to one end user by their JWT — their direct grants plus live group-member grants. On App, takes precedence over userTokenGetter; on Agent, narrows the delegated OAuth grants (agent-owned managed-secret grants are unaffected). An invalid token is a hard 401, never a silent fall-through to app scope. |
limit | number | 100 | Page size (1..1000). |
offset | number | 0 | Page offset. |
Returns UnifiedGrantListResult. Each entry is either an OAuthGrantItem or a ManagedSecretGrantItem; branch on grantKind.
const page = await agent.listGrants({ limit: 50 });for (const grant of page.grants) { if (grant.grantKind === "oauth") { console.log(grant.providerId, grant.scopes); } else { console.log(grant.managedSecretSlug, grant.label); }}if (page.hasMore) { // Fetch next page with offset = page.offset + page.limit.}Throws AlterValueError for out-of-range limit / offset / providerId.
revokeGrant
Section titled “revokeGrant”Revoke a grant. App-only.
async revokeGrant( grantId: string, options?: RevokeGrantOptions,): Promise<RevokeGrantResult>| Option | Type | Description |
|---|---|---|
reason | string | Free-form reason stored on the audit row. |
Returns RevokeGrantResult carrying the grant ID, success flag, and revocation timestamp.
await app.revokeGrant(grantId, { reason: "User requested account deletion" });Revoking an OAuth grant also cascades the revocation to every agent delegation on it. With an agent key, the revoke is bound to the grants the agent is the principal of (its own delegations and agent-owned grants); any other grant rejects with GrantNotFoundError, regardless of the agent’s capability allowlist.
mintGrant
Section titled “mintGrant”Mint a sibling grant on an existing grant’s credential without starting a new authorization flow. App-only.
async mintGrant( sourceGrantId: string, options: { label: string; grantPolicy?: Record<string, unknown>; grantTags?: string[]; },): Promise<GrantInfo>| Parameter | Type | Description |
|---|---|---|
sourceGrantId | string | Active grant owned by the application. Its credential and principal binding are reused. |
label | string | Required sibling address. It must be unique among active grants on the credential. |
grantPolicy | Record<string, unknown> | Optional per-grant policy. expiresAt and maxTtlSeconds use this SDK’s camelCase (the same GrantPolicyInput keys createConnectSession takes) and are validated locally; the wider policy grammar (restrictions, requires_approval, …) uses the backend’s snake_case keys and is validated server-side. A default TTL is not part of the mint grammar — defaultTtlSeconds (either spelling) throws AlterValueError; it is accepted only by the Connect-session methods and createManagedSecretGrant. Supplying both spellings of one camelCase field also throws. |
grantTags | string[] | Optional non-empty tag strings. |
The new grant copies the source principal binding but not its delegations. It can only tighten access through its own policy.
const sibling = await app.mintGrant(sourceGrantId, { label: "read-only", grantPolicy: { expiresAt: "2026-08-01T00:00:00Z", restrictions: { allowed_methods: ["GET"] }, }, grantTags: ["reporting"],});console.log(sibling.grantId, sibling.credentialId, sibling.label);Returns GrantInfo. Throws AlterValueError for malformed local input, GrantNotFoundError when the source grant is unavailable, SiblingLabelConflictError when the label is already active, PolicyViolationError for an agent caller, and BackendError for invalid policy or grant-limit responses.
revokeDelegation
Section titled “revokeDelegation”Revoke an agent’s delegation on a grant. The grant itself stays active; only the named agent loses access.
// App: operator revokes a named agent's delegation.async revokeDelegation(grantId: string, agentId: string): Promise<void>
// Agent: agent revokes its own delegation on a grant.async revokeDelegation(grantId: string): Promise<void>Both paths are idempotent.
// Operator path.await app.revokeDelegation(grantId, agentId);
// Self-revoke path — the agent removes its own access.await agent.revokeDelegation(grantId);Throws AlterValueError when agentId is missing on the App path.
delegate
Section titled “delegate”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 delegate( grantId: string, toAgentId: string, options?: { delegable?: boolean; scopeConstraint?: string[] | null; ttlSeconds?: number | null; },): Promise<DelegationResult>| Parameter | Type | Description |
|---|---|---|
grantId | string | A grant the calling agent already holds. |
toAgentId | string | The agent that should receive the child grant. |
delegable | boolean | Whether the recipient may re-delegate the child onward. Opt-in per hop. Default false. |
scopeConstraint | string[] | null | Narrow the child to a subset of the parent’s provider scopes. Provide a non-empty array 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 array are rejected (a wildcard never narrows). Omit or pass null 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 proxyRequest(). |
ttlSeconds | number | null | Positive-integer lifetime cap in seconds. Omit or pass null for no child-specific cap. A value 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 (chosen at connect time, within the developer’s delegation policy). A child can only narrow — fewer scopes, a shorter lifetime — never widen.
Returns DelegationResult — the freshly minted child grant (grantId, parentGrantId, depth, delegable, status, expiresAt).
Throws:
GrantNotDelegableError— the held grant is not delegable, or a managed secret’s operator ceiling blocks re-delegable grants (HTTP 403grant_not_delegable).GrantNotFoundError— the caller does not holdgrantId.AlterValueError— empty inputs, a malformed scope constraint, or a non-positive/non-integerttlSeconds.BackendError— HTTP 422invalid_scope_constraint: ascopeConstraintatom outside the parent’s effective scopes (the existing child, if any, is unchanged).
import { Agent, GrantNotDelegableError } from "@alter-ai/alter-sdk";
const agent = new Agent({ apiKey: "alter_ak_…" });
try { const child = await agent.delegate("…", "…", { scopeConstraint: ["resource:write"], ttlSeconds: 3600, }); console.log(child.grantId, child.parentGrantId, child.depth);} catch (e) { if (e instanceof GrantNotDelegableError) { // The held grant cannot be passed on — request a delegable grant instead. } else { throw e; }} finally { await agent.close();}listGrants() returns each grant with parentGrantId (the grant it was minted under, null for a root) and depth (its distance from the root), so the full delegation chain reconstructs from the flat list.
createManagedSecretGrant
Section titled “createManagedSecretGrant”Provision a managed-secret grant. App-only.
async createManagedSecretGrant( managedSecretId: string, options: { principal: Principal; grantPolicy?: GrantPolicyInput; },): Promise<CreateGrantResult>The principal field is a discriminated union — TypeScript narrows it based on the type discriminator. Each principal kind binds the grant to a different identity surface; the label lives on the principal itself (required for user/group bindings, optional otherwise):
| Principal kind | type | Required fields | When to use |
|---|---|---|---|
UserPrincipal | "user" | userToken, label | One end user (resolved from JWT). |
GroupPrincipal | "group" | externalGroupId, idpId, label | All members of an IDP group inherit access. 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. |
SystemPrincipal | "system" | label (optional) | Server-to-server. No caller identity. |
AgentPrincipal | "agent" | — | Not accepted by this SDK method. Create agent-bound managed-secret grants via the Developer Portal flow instead. |
The optional grantPolicy overrides the TTL bounds inherited from the parent managed secret:
| Field | Type | Description |
|---|---|---|
expiresAt | string | null | Absolute ISO 8601 expiry. |
maxTtlSeconds | number | null | Cap on requested credential TTL. |
defaultTtlSeconds | number | null | Default TTL when callers don’t request one. |
import type { Principal } from "@alter-ai/alter-sdk";
const principal: Principal = { type: "user", userToken: callingUserJwt, label: "production-read-only",};
const grant = await app.createManagedSecretGrant("ms_…", { principal, grantPolicy: { maxTtlSeconds: 3600 },});console.log(grant.grantId, grant.principalType);Returns CreateGrantResult with the new grantId, the resolved principal type, and the label.
getApprovalStatus
Section titled “getApprovalStatus”Poll an approval row. By default returns immediately; pass wait to have the server hold the request open until the status changes.
async getApprovalStatus( approvalId: string, options?: { wait?: number },): Promise<ApprovalStatus>options.wait is an optional number of seconds to wait for a status change before returning. 0 (the default) returns immediately; 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, with an identical response shape either way. The maximum is 25; larger values are rejected with a validation error, thrown as a BackendError. Most callers should prefer awaitApproval, which manages the wait loop and returns the result.
Returns ApprovalStatus. The status field is one of:
"pending"— no approver decision yet."approved"— approver said yes; backend is about to execute."executing"— backend is calling the provider."executed"— finished; the result blob is available."denied"— approver said no."expired"— approval window elapsed without a decision."failed"— backend errored while executing the proxied call.
The returned ApprovalStatus exposes a derived isTerminal boolean — true when status is "denied", "expired", "executed", or "failed". Use result.isTerminal to check whether further polling is pointless.
Throws BackendError, NetworkError, TimeoutError.
awaitApproval
Section titled “awaitApproval”Poll until the approval reaches a terminal state, then return the result. Transient 502 / 503 / 504 responses and transport blips are retried within the deadline; permanent failures propagate immediately.
async awaitApproval( approvalId: string, options?: { timeoutMs?: number; pollIntervalMs?: number },): Promise<ApprovalResult>| Option | Type | Default | Description |
|---|---|---|---|
timeoutMs | number | 300_000 (5 min) | Maximum local wait. Milliseconds. |
pollIntervalMs | number | 2_000 | Fallback cadence between polls. 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. Milliseconds. |
Returns ApprovalResult when the row reaches executed.
Throws:
ApprovalDeniedError— approver denied.ApprovalExpiredError— approval window elapsed.ApprovalExecutionFailedError— backend failed to execute after approval was granted.ApprovalTimeoutError— local wait elapsed before any decision was recorded. When the wait elapsed because the backend was returning transient errors, the message names the underlying cause and the original exception is preserved on.cause.
try { const final = await app.awaitApproval(approvalId, { timeoutMs: 10 * 60_000 }); return final.bodyJson();} catch (error) { if (error instanceof ApprovalDeniedError) { // Surface "request was rejected" to the user. } throw error;}Reading the response body
Section titled “Reading the response body”ApprovalResult carries the provider response as base64.
final.bodyBytes(); // Uint8Arrayfinal.bodyText(); // string (utf-8 default)final.bodyText("latin1");final.bodyJson(); // parsed JSONfinal.bodyTruncated is true if the backend truncated the body (very large responses).
Surfacing PendingApproval to the user
Section titled “Surfacing PendingApproval to the user”PendingApproval .approvalUrl is the deep link to the approver’s wallet UI. Surface it through whatever channel the application uses — modal, redirect, chat message, or push notification. Email is sent automatically when the backend has an email provider configured.
if (result instanceof PendingApproval) { await notifyApprover({ title: "Approval requested", expiresAt: result.expiresAt, url: result.approvalUrl, }); await app.awaitApproval(result.approvalId, { timeoutMs: 60 * 60_000 });}