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_…, or legacyalter_key_…). - For OAuth delegation flows: at least one provider configured and an identity provider wired.
- 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: Agents → New 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.
import os
result = await app.agents.create( name="research-bot", scopes={ "google": [ "openid", "email", "https://www.googleapis.com/auth/calendar.readonly", ], "managed_secrets": ["tavily-production"], },)print("agent id:", result.id)
# result.api_key is the plaintext, returned exactly ONCE. 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.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_secrets: ["tavily-production"], },});console.log("agent id:", result.id);
// result.apiKey is the plaintext, returned exactly ONCE. 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.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 FIRSTWhen 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) lets the recipient agent later re-delegate the grant onward to another agent — see Delegation chains below. Default off: onward delegation is opt-in at every hop.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] → New 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:
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 first-hop grant was minted with delegable=True, the holding agent can hand a narrowed child to another agent — without the credential owner re-consenting:
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 hop is itself non-delegable unless delegable is set again. Delegating a grant that was not minted delegable raises GrantNotDelegableError. Revoking any grant cascades down its whole chain.
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. A paused agent’s credential calls — request() and proxy_request() — raise AgentInactiveError.
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, or issue a managed-secret grant to the agent. |
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. | Update the agent’s capability allowlist in the portal or through app.agents.update(). |