Guides
Give an AI Agent Scoped Access
Provision a managed agent, bind it to credentials, and let it call providers — with its own identity and audit trail.
By the end of this guide, an AI agent has its own API key, can call third-party APIs through Alter, and reaches only the credentials explicitly bound to it — not the app’s full grant store.
The flow:
- The operator provisions a managed agent.
- The operator binds credentials to the agent (a managed-secret grant directly, or a user delegates an OAuth connection to the agent).
- The agent process loads its own API key and calls
agent.request()for an agent-owned managed secret oragent.proxy_request()for a delegated grant.
Prerequisites
Section titled “Prerequisites”- An app with a runtime API key (
alter_rk_…). The operator calls in this guide need these scopes on that key:agents:writeto create an agent,agents:adminto suspend or resume one, andconnect:initiateplusgrants:writefor the Connect session in Path A. The portal’s Backend service key template does not include the agent scopes; add them when you create the key. An app key in the olderalter_key_…format was created before the September 2026 key-security upgrade and no longer authenticates — mint a new one. - For OAuth delegation flows: at least one provider configured and an identity provider wired for the app.
- For managed-secret flows: at least one managed-secret provider configured.
Walkthrough
Section titled “Walkthrough”1. Provision the agent
Section titled “1. Provision the agent”In the developer portal: Agent Identity → Create agent. Pick a name (research-bot) and an explicit capability allowlist, then click Create.
Equivalent SDK call:
The examples use a managed secret whose slug is tavily-production and a
Google connection authorized for read-only Calendar access. The operator
snippets in steps 1 and 2 are fragments: they run inside an async function
that holds an App client (async with App(api_key=os.environ["ALTER_API_KEY"]) as app:
in Python, const app = new App({ apiKey: process.env.ALTER_API_KEY! }) in
TypeScript). See Running the examples in the
Python and
TypeScript SDK references.
import os
result = await app.agents.create( name="research-bot", scopes={ "google": [ "openid", "email", "https://www.googleapis.com/auth/calendar.readonly", ], "managed_secret_slugs": ["tavily-production"], },)print("agent id:", result.id)
# result.api_key is the plaintext, returned exactly ONCE — it is None on an# idempotent replay, so read it back from wherever the first call stored it.# Hand it straight to the secret store the agent process reads from — do not# print it. Anything written to stdout survives in terminal scrollback, shell# history and CI logs, which is where leaked credentials are usually found.if result.api_key is None: raise RuntimeError("agent already exists — use the key stored at creation")os.environ["AGENT_API_KEY"] = result.api_keyconst result = await app.agents.create({ name: "research-bot", scopes: { google: [ "openid", "email", "https://www.googleapis.com/auth/calendar.readonly", ], managed_secret_slugs: ["tavily-production"], },});console.log("agent id:", result.id);
// result.apiKey is the plaintext, returned exactly ONCE — it is null on an// idempotent replay, so read it back from wherever the first call stored it.// Hand it straight to the secret store the agent process reads from — do not// log it. Anything written to stdout survives in terminal scrollback, shell// history and CI logs, which is where leaked credentials are usually found.if (result.apiKey === null) { throw new Error("agent already exists — use the key stored at creation");}process.env.AGENT_API_KEY = result.apiKey;The plaintext API key is returned exactly once — agents.create is a key-minting call, not only a create call. Store it where the agent process will read it (a secret manager, an environment variable, the runtime’s secrets API), and never print or log it. Per-agent keys currently mint with the legacy alter_key_… prefix; a scoped alter_ak_… key from App → API Keys is agent-typed but is bound to no managed-agent record, so it cannot stand in for this one.
Using the CLI instead, redirect the output rather than letting it print:
alter agents create --name research-bot --scopes '<allowlist-json>' \ --output json > .alter-agent-key.json # add to .gitignore FIRSTThe later snippets refer to the new agent’s ID as agent_id (agentId in TypeScript): that is result.id from the call above, or the id field of the CLI’s JSON output.
When you need to freeze the workload without deleting its identity, suspend the agent instead of revoking it:
await app.agents.suspend(agent_id)await app.agents.resume(agent_id)await app.agents.suspend(agentId);await app.agents.resume(agentId);2. Bind credentials to the agent
Section titled “2. Bind credentials to the agent”Two paths, depending on whether the credential belongs to a user or the operator.
Path A — user delegates a connection. When the user runs Connect, pass agent=<agent_id>:
session = await app.create_connect_session( allowed_providers=["google"], user_token=user.jwt, agent=agent_id, # consent screen will show "research-bot is requesting access")const session = await app.createConnectSession({ allowedProviders: ["google"], userToken: user.jwt, agent: agentId,});On Approve, Alter creates or reuses the requested grant and creates a delegation binding that grant to the named agent. If the user is not connected yet, the OAuth flow creates the connection first.
Two optional knobs on that same call shape the delegated grant:
delegable=True(delegable: truein TypeScript) asks the user to let the recipient agent later re-delegate the grant onward to another agent — see Delegation chains below. It is a request: the consent screen shows the user a May share this access with other agents checkbox, and only the user’s choice is stored. The user signs in through the application’s identity provider before choosing (the screen after a provider login shows Sign in to choose first; embedded Connect shows that screen in the window where the user signed in to the provider, so the choice is offered there too). A screen 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, offers no checkbox: a new delegation is created as a leaf and an existing one keeps its setting. The checkbox appears only when the provider’s Agent sharing setting is on (Developer Portal, the provider’s page under OAuth, oralter providers set-delegation-policy --delegable), except that an existing delegation that can already be shared onward keeps it, turned on, after the setting is turned off, and the user can then only turn it off; a provider configured after agent sharing was introduced starts with the setting off. Settingdelegable=Truealso shows the consent screen for adirectsession. The stored outcome comes back on each result’sdelegation(agent_id,delegable, andpolicy_refusedwhen the provider’s setting refused the user’s choice as the connection was confirmed). A new delegation is a leaf whendelegableis omitted: onward delegation is opt-in at every hop. Omitting it when the user re-consents for an agent that already holds a delegation keeps that delegation’s existing setting;delegable=Falseturns it off, with no checkbox.scope_constraint=[...](scopeConstraintin TypeScript) clamps the agent’s grant to a subset of the credential’s provider scopes (least privilege). A scope-narrowed grant is proxy-only, and if the constraint admits nothing against a provider’s required scopes, the returned session’sscope_constraint_warningssays so.
Path B — operator issues a managed secret to the agent. From the Developer Portal: Managed Secrets → [Provider] → Add Grant → Agent → research-bot (the Alter CLI’s managed-secrets grant commands support agent principals too). Agent-bound grants are an operator action: the SDK’s create_managed_secret_grant / createManagedSecretGrant methods accept an agent principal at the type level, but the backend rejects it (HTTP 422) — use the Portal or CLI. The resulting grant_id is what the agent process uses in request(grant_id=...).
3. Call providers from the agent
Section titled “3. Call providers from the agent”The agent process loads its own key and calls request() exactly as an app would. The example reads three environment variables: AGENT_API_KEY (the key from step 1), TAVILY_GRANT_ID (the grant_id of the agent-bound managed-secret grant from Path B), and, for the delegated call, USER_JWT (the end user’s IDP-issued JWT; in a running service it comes from the incoming request rather than the environment):
import asyncio, osfrom alter_sdk import Agent, HttpMethod
async def main(): async with Agent(api_key=os.environ["AGENT_API_KEY"]) as agent: # Use a managed-secret grant. The json body is forwarded to the provider # verbatim — use the provider's wire field names. response = await agent.request( HttpMethod.POST, "https://api.tavily.com/search", grant_id=os.environ["TAVILY_GRANT_ID"], json={ "query": "Latest developments in retrieval-augmented generation", "max_results": 5, }, ) print(response.status_code, response.json())
# Or use a delegated OAuth grant via provider+user_token, where # user_jwt is the end user's IDP-issued JWT. user_jwt = os.environ["USER_JWT"] response = await agent.proxy_request( HttpMethod.GET, "https://www.googleapis.com/calendar/v3/calendars/primary/events", provider="google", user_token=user_jwt, ) print(response.status_code)
asyncio.run(main())import { Agent, HttpMethod } from "@alter-ai/alter-sdk";
const agent = new Agent({ apiKey: process.env.AGENT_API_KEY! });
try { // The json body is forwarded to the provider verbatim — use the provider's // wire field names. const response = await agent.request( HttpMethod.POST, "https://api.tavily.com/search", { grantId: process.env.TAVILY_GRANT_ID!, json: { query: "Latest developments in retrieval-augmented generation", max_results: 5, }, }, ); console.log(response.status, await response.json());} finally { await agent.close();}The audit events carry the agent identity, the user identity (if a delegation was used), and the provider.
Patterns
Section titled “Patterns”Listing the agent’s bound grants
Section titled “Listing the agent’s bound grants”page = await agent.list_grants()for g in page.grants: print(g.grant_kind, g.provider_id if g.grant_kind == "oauth" else g.managed_secret_slug)OAuth and managed-secret grants come back in one merged response, discriminated by grant_kind.
Delegation chains and scope narrowing
Section titled “Delegation chains and scope narrowing”When the user allowed onward sharing for the first-hop grant, the holding agent can hand a narrowed child to another agent — without the credential owner re-consenting. The credential’s operator ceiling (the OAuth provider’s Agent sharing setting, or a managed secret’s delegation policy) must still allow it on every call, and its depth bounds how far the chain can go:
result = await agent.delegate( grant_id, # a grant THIS agent holds downstream_agent_id, scope_constraint=["chat:write"], # subset of the parent's provider scopes ttl_seconds=3600, # child expiry ≤ min(this, parent's expiry))print(result.grant_id, result.depth) # depth 2+ = an onward hopconst result = await agent.delegate(grantId, downstreamAgentId, { scopeConstraint: ["chat:write"], ttlSeconds: 3600,});console.log(result.grantId, result.depth);A child can preserve or narrow the parent’s scope ceiling and can never outlive its parent; it cannot widen either boundary. Each new hop is itself non-delegable unless delegable is set again; re-delegating to an agent that already holds a child of the grant without delegable keeps that child’s setting. Delegating a grant that was not minted delegable, or one whose operator ceiling is now off, raises GrantNotDelegableError. Revoking any grant cascades down its whole chain.
The recipient agent’s capability allowlist only needs to cover the narrowed scope set, not the credential’s full scope list — a child narrowed to read:user runs on an agent allowlisted for exactly ["read:user"] (least privilege end to end). An allowlist entry that subsumes the narrowed scope (for example GitHub’s user, which grants read:user) also qualifies.
Requests through a narrowed grant are checked per operation against the narrowed set. An operation whose provider-scope requirement cannot be determined is refused (scope_narrowing_unenforceable) rather than let through — unless a restriction policy rule on the delegated grant (or its holding agent) explicitly pins the allowed methods and endpoints, in which case that pin bounds the traffic. Rules attached at the application or organization level do not lift this refusal: only a pin authored on the delegation itself does.
Self-introspection
Section titled “Self-introspection”me = await agent.me()print(me.name, me.status, me.scopes)me() works even if the agent is paused — useful for self-diagnostics. Every other call from a paused agent’s key — request(), proxy_request(), list_grants(), delegate(), Connect session minting, key derivation, catalog reads — raises AgentInactiveError: suspension is containment, so a paused agent cannot enumerate its grants or acquire new access, whichever resolution mode the call uses. Two deliberate carve-outs besides me(): span emission and the SDK’s automatic provider-call audit reporting keep working while paused, so suspending an agent never blinds the audit trail it is being investigated with.
Trace-scoped audit identity
Section titled “Trace-scoped audit identity”For multi-step workflows where each step should be attributed to a sub-agent:
async with researcher.trace(run_id="run_abc", role="research"): # Every nested request() is tagged with researcher + run_abc. await researcher.request(...)See Agent.trace() in the Python SDK reference.
Choosing the agent process boundary
Section titled “Choosing the agent process boundary”Two shapes of agent process:
- Standalone — the agent runs in its own process / container / sandbox with only its agent key. Best for untrusted runtimes (user-installed MCP servers, sandboxed code).
- Embedded — the agent runs inside the main app process; the app holds the agent key alongside its app key. Best for backends where adding another key boundary buys nothing.
Both shapes produce the same backend identity. The decision is purely a blast-radius question.
Troubleshooting
Section titled “Troubleshooting”| Error | Likely cause | Fix |
|---|---|---|
NoDelegatedGrantError | The agent has no delegation for the requested provider. | Run a Connect session with agent=<this_agent_id> so a user can delegate, have the agent ask the user for a managed secret with agent.create_managed_secret_connect_session() / agent.createManagedSecretConnectSession(), or issue a managed-secret grant to the agent. |
GrantNotDelegableError from delegate() | detail_reason names the cause: holder_not_delegable (the user did not allow onward sharing for the held grant), ceiling_off (the credential’s operator ceiling is off: the OAuth provider’s Agent sharing setting, or the managed secret’s delegation policy), no_consenting_user (the held grant was made directly to an agent, with no consenting user), or no_onward_hop (delegable=True asked for a child the depth ceiling leaves no further hop). | holder_not_delegable: ask the user again through a Connect session with delegable=True. ceiling_off: an administrator turns sharing on for the provider (alter providers set-delegation-policy --delegable) or the secret (alter managed-secrets set-delegation-policy <secret-id> --delegable). no_consenting_user: such a grant can never be shared onward; have a user delegate it. no_onward_hop: call again without delegable=True, or an administrator raises the depth ceiling. |
AgentInactiveError | The operator paused the agent. | Resume from the portal. |
AgentRevokedError | The operator hard-revoked the agent. | Provision a new agent. |
KeyRevokedError | The specific API key was revoked. | Mint a fresh key for the agent. |
InsufficientScopeError | The API key’s Alter scope set does not cover the SDK route. | Use a key whose Alter scopes include the missing capability. |
PolicyViolationError with policy_error == "agent_scope_not_allowed" | The agent’s provider or managed-secret allowlist does not cover the resolved grant’s effective scopes (for a scope-narrowed or delegated grant, the narrowed set — not the credential’s full list). | Update the agent’s capability allowlist in the portal or through app.agents.update(). |