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.
  • An app with a runtime API key (alter_rk_…). The operator calls in this guide need these scopes on that key: agents:write to create an agent, agents:admin to suspend or resume one, and connect:initiate plus grants:write for 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 older alter_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.

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

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

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

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) 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, or alter 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. Setting delegable=True also shows the consent screen for a direct session. The stored outcome comes back on each result’s delegation (agent_id, delegable, and policy_refused when the provider’s setting refused the user’s choice as the connection was confirmed). A new delegation is a leaf when delegable is 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=False turns it off, with no checkbox.
  • 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] → 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=...).

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, 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 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 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 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.

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.

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, 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.
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’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().

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.