Skip to content

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:

  1. The operator provisions a managed agent.
  2. The operator binds credentials to the agent (a managed-secret grant directly, or a user delegates an OAuth connection to the agent).
  3. The agent process loads its own API key and calls agent.request() for an agent-owned managed secret or agent.proxy_request() for a delegated grant.

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_key
const 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:

Terminal window
alter agents create --name research-bot --scopes '<allowlist-json>' \
--output json > .alter-agent-key.json # add to .gitignore FIRST

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);

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: true in 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=[...] (scopeConstraint in 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’s scope_constraint_warnings says 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=...).

The agent process loads its own key and calls request() exactly as an app would:

import asyncio, os
from 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.

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.

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 hop
const 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.

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.

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.

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.

ErrorLikely causeFix
NoDelegatedGrantErrorThe 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.
AgentInactiveErrorThe operator paused the agent.Resume from the portal.
AgentRevokedErrorThe operator hard-revoked the agent.Provision a new agent.
KeyRevokedErrorThe specific API key was revoked.Mint a fresh key for the agent.
InsufficientScopeErrorThe 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().

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.