Skip to content

Reference

Agents & Keys

Managed agents (App.agents namespace), Agent runtime (me/trace/with_constraints), scoped key lifecycle (keys namespace).

This page covers the full lifecycle of managed agents and the scoped-key primitives they’re built on:

# Operator side
created = await app.agents.create(
name="support-bot",
scopes={"provider-id": ["resources:read", "resources:write"]},
)
page = await app.agents.list()
agent_info = await app.agents.get(agent_id)
await app.agents.update(agent_id, display_name="Support Bot v2")
await app.agents.suspend(agent_id)
await app.agents.resume(agent_id)
await app.agents.delete(agent_id)
# Workload side
me = await agent.me()
async with agent.trace(run_id="run_42"):
await agent.request(...)
narrowed = agent.with_constraints(scopes=["grants:read"])
session = await agent.create_managed_secret_connect_session(
template_slug="provider-api-key", user_token=user_jwt,
)
child = await agent.delegate(grant_id, recipient_agent_id, ttl_seconds=3600)
# Scoped key lifecycle
derived = await app.keys.derive(scopes=["tokens:retrieve"], expires_in=3600)
new_key = await app.keys.rotate(key_id=old.id)
revoked = await app.keys.revoke(key_id=leaked.id)

Create a managed agent and its initial API key.

async def create(
*,
name: str,
scopes: dict[str, list[str]],
display_name: str | None = None,
type: str | None = "agent",
metadata: dict[str, Any] | None = None,
policy: dict[str, Any] | None = None,
idempotency_key: str | None = None,
) -> AgentCreateResult
ParameterTypeDefaultDescription
namestr—Agent name. Lowercase, alphanumeric + dash/underscore. Must be unique among non-revoked agents in the app.
display_namestr | NoneNoneHuman-readable label.
typestr | None"agent""agent" (default) or "service". Explicit None normalizes to "agent".
scopesdict[str, list[str]]—Required per-provider capability allowlist, maximum 12 KiB as compact UTF-8 JSON. Each listed provider must contain at least one scope; an empty dict ({}) provisions a deny-all agent with no provider access until scopes are added. Managed-secret capabilities go under managed_secret_slugs as each secret’s stable, app-unique slug (a display-name rename keeps the permission). The legacy managed_secrets key matches display names and accepts no new entries.
metadatadict | NoneNoneFree-form metadata.
policydict | NoneNoneValidated policy block (HITL config + policy attributes).
idempotency_keystr | NoneNoneOptional Idempotency-Key header. Same key + same body returns identical metadata; the api_key field is None on replay (branch on result.api_key is None). Different body raises IdempotencyKeyBodyMismatchError.

Returns: AgentCreateResult. The plaintext api_key is returned ONCE — store it immediately.

Raises: AlterValueError, AgentNameExistsError, IdempotencyKeyBodyMismatchError, IdempotencyKeyAgentRevokedError, IdempotencyKeyAgentInactiveError, AgentCannotMintSubagentsError, BackendError (including HTTP 422 with details["error"] == "legacy_managed_secret_name_rejected" when scopes lists any entry under the legacy managed_secrets key; nothing is created).

result = await app.agents.create(
name="support-bot",
display_name="Customer Support Bot",
scopes={"provider-id": ["resources:read", "resources:write"]},
metadata={"team": "cs"},
)
agent_api_key = result.api_key # store securely; never recoverable
agent_id = str(result.id)
async def list(
*,
include_revoked: bool = False,
limit: int = 100,
offset: int = 0,
) -> AgentListResult
ParameterTypeDefaultDescription
include_revokedboolFalseInclude tombstoned agents.
limitint100Requested page size, 1-1000. The server returns at most 10 agents per page.
offsetint0Page offset.

Returns: AgentListResult.

page = await app.agents.list(limit=10)
for a in page.agents:
print(a.id, a.name, a.status)
while page.has_more:
page = await app.agents.list(limit=10, offset=page.offset + page.limit)
...

Fetch a single agent by id.

async def get(agent_id: str) -> AgentInfo

Raises: AlterValueError, AgentNotFoundError.

Fetch an agent by exact name. Returns None if no active agent matches — cheap “does it exist?” lookup.

async def get_by_name(name: str) -> AgentInfo | None
existing = await app.agents.get_by_name("support-bot")
if existing is None:
result = await app.agents.create(
name="support-bot",
scopes={"provider-id": ["resources:read"]},
)

Update mutable fields on an agent.

async def update(
agent_id: str,
*,
display_name: str | None = None,
metadata: dict[str, Any] | None = None,
scopes: dict[str, list[str]] | None = None,
expected_version: int | None = None,
grace_seconds: int | None = None,
policy: dict[str, Any] | None = None,
) -> AgentInfo
ParameterTypeDefaultDescription
agent_idstr—Agent to update. Positional.
display_namestr | NoneNoneNew display name.
metadatadict | NoneNoneReplaces the metadata block atomically.
scopesdict[str, list[str]] | NoneNoneFull desired allowlist, maximum 12 KiB as compact UTF-8 JSON. Requires expected_version. Broadening applies immediately; narrowing is staged for the grace period. Managed secrets go under managed_secret_slugs by slug; entries the agent already stores under the legacy managed_secrets key may be kept or removed, never added.
expected_versionint | NoneNonePositive AgentInfo.version from the snapshot used to build scopes. Required whenever scopes is supplied.
grace_secondsint | NoneNoneNarrowing grace, 0–604800. Requires scopes; 0 applies immediately. Omit to use the deployment default (24 hours).
policydict | NoneNoneReplaces the policy block atomically.

Passing all parameters as None is a no-op round-trip that returns the unchanged agent. Each field is optional independently.

Raises: AlterValueError, AgentNotFoundError, AgentConcurrentUpdateError, BackendError (HTTP 422 legacy_managed_secret_name_rejected when scopes adds a name under the legacy managed_secrets key; nothing is saved).

snapshot = await app.agents.get(agent_id)
updated = await app.agents.update(
agent_id,
display_name="Customer Support Bot v2",
scopes={"provider-id": ["resources:read", "resources:write"]},
expected_version=snapshot.version,
grace_seconds=3600,
)

If another operator changes the agent after snapshot was read, the backend raises AgentConcurrentUpdateError. Fetch the agent again, recompute the full desired allowlist from that fresh record, and retry with its version. Never blindly replay scopes built from the stale snapshot: doing so could restore access the other operator removed.

Pause and later restore an agent without revoking its identity.

async def suspend(agent_id: str) -> AgentInfo
async def resume(agent_id: str) -> AgentInfo
  • suspend() flips status to "inactive". Every call made with that agent’s key — agent.request(), agent.proxy_request(), agent.list_grants(), agent.delegate(), Connect session minting, key derivation, catalog reads — then raises AgentInactiveError: suspension is containment, so a paused agent can neither enumerate its grants nor acquire new access. agent.me() keeps working so a paused workload can self-diagnose, and telemetry ingestion (span emission and the SDK’s automatic provider-call audit reporting) keeps flowing so pausing an agent never blinds its audit trail.
  • resume() flips status back to "active". Any overdue pending scope narrowing stays on the row and applies on the next auth check or sweeper pass.
await app.agents.suspend(agent_id)
await app.agents.resume(agent_id)
async def delete(agent_id: str) -> AgentInfo

Returns: the updated AgentInfo with status="revoked".

Raises: AlterValueError, AgentNotFoundError.

Mint a subsequent API key for an existing agent. Plaintext returned ONCE.

async def mint_key(agent_id: str) -> AgentKeyMintResult

Returns: AgentKeyMintResult.

Raises: AlterValueError, AgentNotFoundError, AgentCannotMintSubagentsError, AgentKeyLimitError (the agent is at its active-key cap — revoke an existing key to free a slot).

Every key for the agent, ordered by created_at ascending. Plaintext is never included.

async def list_keys(agent_id: str) -> AgentKeyList
async def deprecate_key(agent_id: str, key_id: str) -> AgentKey
async def undeprecate_key(agent_id: str, key_id: str) -> AgentKey

Mark a key as deprecated (it still authenticates; every authenticated response carries X-Alter-Key-Deprecated: true, and a client using the key emits a one-time UserWarning through the warnings module so operations dashboards can spot “time to roll the workload”) or clear the marker. Idempotent.

Raises: AlterValueError, AgentNotFoundError, KeyNotFoundError, KeyAlreadyRevokedError.

Revoke a single key (terminal). Default-protective: refuses to revoke the last non-revoked key on an agent unless force=True.

async def revoke_key(
agent_id: str,
key_id: str,
*,
force: bool = False,
) -> AgentKey
ParameterTypeDefaultDescription
agent_idstr—Positional.
key_idstr—Positional.
forceboolFalseBypass the last-active-key guard.

Raises: AlterValueError, AgentNotFoundError, KeyNotFoundError, LastActiveKeyError.


Self-introspection — returns the calling agent’s own record. Carved out from the policy pipeline so paused or revoked agents can still call me() to self-diagnose (per-key revoked_at is still enforced).

async def me() -> AgentInfo

Returns: AgentInfo.

Raises: MeRequiresAgentKeyError (called with an app key), KeyRevokedError.

from alter_sdk import Agent
agent = Agent(api_key="alter_key_…") # the agent's own key, from agents.create
info = await agent.me()
print(info.id, info.name, info.status, info.version)

Async context manager that scopes audit identity for every nested call. Tags every request() made inside the block — on this agent, on the parent App, or via framework-bridged code — with this agent’s identity, run_id, thread_id, and arbitrary metadata.

def trace(
*,
run_id: str | None = None,
thread_id: str | None = None,
parent: str | None | object = ...,
**metadata: str,
) -> AbstractAsyncContextManager[None]
ParameterTypeDefaultDescription
run_idstr | NoneNoneCorrelation id for the run (e.g. LangGraph run id). Inherited from an outer trace() block if omitted.
thread_idstr | NoneNoneConversation thread id. Inherited from an outer block if omitted.
parentstr | None | ...... (sentinel)... (default): ambient detection of an outer trace() block from a different agent. None: explicit “no parent”. str: explicit parent agent name.
**metadatastr values—Free-form str → str pairs merged into the audit context. Reserved keys (agent, parent_agent, run_id, thread_id, tool, tool_call_id, framework) raise AlterValueError.

ContextVar isolation is per asyncio Task — concurrent trace() blocks under asyncio.gather stay isolated. asyncio.create_task inside the block inherits the snapshot; outside the block, child tasks do not.

async with agent.trace(run_id="run_abc", role="writer"):
await agent.request("POST", url, json=payload)
async with researcher.trace():
# parent_agent=<outer agent> via ambient detection
await researcher.request("GET", other_url)

Return a macaroon-style constrained sub-Agent. Every request from the returned Agent carries a permanent attenuation that can only narrow access, never broaden it. Two attenuations are available, and at least one of scopes / rule must be supplied:

  • scopes narrows the underlying key to the supplied scope set. The supplied scopes must already be implied by the key — the backend rejects broadening attempts with HTTP 400 constraint_not_narrowing.
  • rule attaches an optional request rule, evaluated server-side, that can only further restrict each request the returned client makes — it can never widen access. Supported shapes include json_match deny rules, require_approval HITL rules, and content_match rules built with content_match_rule(...). A raw-token retrieval carries no request method/URL, so method-keyed conditions are treated as satisfied there (a raw token can issue any method) — scope method restrictions through the proxy surface, which carries the request method. If a request is frozen for human approval (HITL 202), the per-request rule is not re-evaluated on the deferred post-approval execution — it cannot widen access (the rule did not match the frozen request, and the grant plus every stored policy rule still bound the execution), but prefer a stored rule when the constraint must hold at execute time.
def with_constraints(*, scopes: list[str] | None = None, rule: RequestRule | dict[str, Any] | None = None) -> Agent
ParameterTypeDefaultDescription
scopeslist[str] | NoneNoneOptional non-empty list of scope strings to attenuate to.
ruleRequestRule | dict | NoneNoneOptional request rule carried on every request the returned client makes; enforced on credential-using calls (token retrieval, proxied provider requests).

At least one of scopes / rule is required.

Raises: AlterValueError (neither scopes nor rule supplied, malformed scopes / rule, nested constraint call), AlterSDKError (client closed).

See also App.with_constraints() — same semantics for the operator client.

narrowed = agent.with_constraints(scopes=["grants:read"])
await narrowed.list_grants()
# Attach a request rule:
restricted = agent.with_constraints(
rule={
"rule_type": "json_match",
"rule_body": {"when": {"method": "POST"}, "effect": "deny"},
},
)

Ask a user to delegate their managed-secret access to this agent. It is the agent-side form of App.create_managed_secret_connect_session(): the same session, the same sign-in and approval page, the same outcome delivery and the same return value, with no target parameter.

async def create_managed_secret_connect_session(
*,
template_slug: str,
user_token: str,
requested_ttl_seconds: int | None = None,
allowed_origin: str | None = None,
return_url: str | None = None,
allow_user_policy_rules: bool = True,
delegable: bool = False,
) -> ManagedSecretConnectSession
ParameterTypeDefaultDescription
template_slugstr—Canonical template slug (e.g. "provider-api-key"). Kebab-case required.
user_tokenstr—IDP JWT identifying the user being asked. Required.
requested_ttl_secondsint | NoneNoneCaller-suggested TTL, capped as on the App method.
allowed_originstr | NoneNonePopup-flow postMessage origin: a single canonical origin, as on the App method (otherwise HTTP 400 invalid_origin).
return_urlstr | NoneNoneMobile-redirect return URL.
allow_user_policy_rulesboolTrueWhether the user may add narrowing-only usage limits during approval.
delegableboolFalseAsk the user to let this agent share the access onward; the Connect page says next to its checkbox who asked: the agent itself for its own key, the application for an Agent from App.get_agent(). For a new delegation the checkbox starts off when the agent’s own key asked, so the user opts the agent in, and on for an Agent from App.get_agent(), whose request comes from the application; an existing delegation starts from its stored setting. Echoed as delegable_requested. See the App method.

Which agent the session is for. The session is always for the agent this client acts as:

  • An Agent built from the agent’s own key (Agent(api_key=...)) sends no agent id; the backend targets the key’s agent.
  • An Agent from App.get_agent() sends its agent id as delegated_agent_id under the application’s key, so the request is exactly the App call for that agent.
  • An Agent constructed directly from an application key, or from an agent-typed alter_ak_… key minted under API Keys (bound to no managed agent), is neither, so it names no agent; the backend refuses the request with HTTP 422 delegated_agent_id_required (BackendError). Use App.get_agent() with an application key, or the managed agent’s own key (alter agents mint-key).

Only an Agent from App.get_agent() receives no_eligible_grants. It uses the application’s key, so like the App method it raises ConnectConfigError with no_eligible_grants when the user holds nothing that could be delegated to this agent. An Agent built directly from a key bound to no managed agent is refused first with delegated_agent_id_required (above). A managed agent’s own key never receives it: session creation does not look at the user’s credentials at all, and a created session has the same fields as one created with an app key, so session creation tells an agent nothing about which credentials a user holds. If the user has nothing to delegate, the Connect page tells the user why after they sign in.

Scopes. The calling key needs connect:initiate and secrets:write, the same pair an app key needs for the App method. Managed-agent keys carry both.

Returns: ManagedSecretConnectSession. On approval the page delivers the new delegation_id (with updated and delegable), which the agent passes as grant_id to proxy_request().

Raises: AlterValueError (None, an empty string or a malformed value for template_slug or user_token, or a non-boolean allow_user_policy_rules / delegable, before any request), TypeError (template_slug or user_token omitted), ConnectConfigError (idp_signin_not_configured; no_eligible_grants from an App.get_agent() client only), ServerFeatureUnsupportedError (the backend predates this method, or ignored delegable=True), AgentInactiveError (the agent is paused), AgentNotFoundError (from an App.get_agent() client whose agent is no longer an active agent of the app; details["error"] == "caller_agent_unresolved"), BackendError (invalid_origin, delegated_agent_id_required, an unknown template, a user token that fails validation, or a user who cannot be synchronized — status_code 503 service_unavailable, transient, with details["retry_after_seconds"], or 409 identity_provider_inactive / identity_group_name_ambiguous, which an administrator must repair), AlterSDKError (client closed), NetworkError, TimeoutError.

import os
from alter_sdk import Agent, ServerFeatureUnsupportedError
async with Agent(api_key=os.environ["AGENT_API_KEY"]) as agent:
try:
session = await agent.create_managed_secret_connect_session(
template_slug="provider-api-key",
user_token=user_jwt, # the end user the agent is acting for
)
except ServerFeatureUnsupportedError:
# The Alter backend predates agent-initiated requests: ask from the
# application (App.create_managed_secret_connect_session) instead.
raise
# Send the user to session.connect_url; after approval, the delegation id
# arrives in the postMessage payload or on return_url.

The keys namespace lets a caller mint short-lived attenuated sub-keys, rotate keys with an overlap window, and revoke keys (with cascade to derived descendants). Available on both App (app.keys) and Agent (agent.keys, when the agent holds keys:derive).

Mint a short-lived attenuated key under the caller’s key. The derived key is a strict narrowing — scopes must be a subset of the caller’s, expires_in capped at org policy, cidr_allowlist a subset of the caller’s. Derived keys cannot themselves derive.

async def derive(
*,
scopes: list[str],
expires_in: int,
cidr_allowlist: list[str] | None = None,
name: str | None = None,
metadata: dict | None = None,
) -> MintedKey
ParameterTypeDefaultDescription
scopeslist[str]—Non-empty list of scope strings. Must be a subset of the caller’s effective scope set. keys:derive is rejected (raises AlterValueError) — derived keys cannot themselves derive.
expires_inint—Lifetime in seconds. Positive integer. Capped at the org policy ceiling and the caller’s remaining TTL.
cidr_allowlistlist[str] | NoneNonePer-key IP allowlist. Subset of caller’s. Inherits caller’s value when omitted.
namestr | NoneNoneHuman-readable label. Defaults to derived-YYYYMMDD-HHMMSS on the backend.
metadatadict | NoneNoneFree-form metadata stored on the key.

Returns: MintedKey. The plaintext api_key is returned ONCE.

Raises:

  • AlterValueError — empty scopes, non-string scope entries, non-positive expires_in, keys:derive in scopes.
  • InsufficientScopeError — caller lacks keys:derive.
  • BackendError — scope-not-subset (permission boundary) or other lifecycle failure.
import os
from alter_sdk import App
app = App(api_key="alter_rk_…")
derived = await app.keys.derive(
scopes=["tokens:retrieve", "grants:read"],
expires_in=3600,
cidr_allowlist=["10.0.0.0/8"],
metadata={"purpose": "ci-deploy"},
)
os.environ["CHILD_KEY"] = derived.api_key # plaintext shown ONCE

Rotate an existing key: mint a same-scope successor and deprecate the old key with an overlap window. The old key remains valid for overlap_days so callers can roll out the new secret. Derived (dk) keys cannot be rotated — derive a new one from the parent instead.

A managed-agent key can rotate and revoke only keys bound to its own agent; any other key in the application is reported as not found. An application key can manage every key in its application.

async def rotate(
*,
key_id: str | UUID,
overlap_days: int = 7,
) -> MintedKey
ParameterTypeDefaultDescription
key_idstr | UUID—Key to rotate. Keyword-only.
overlap_daysint7Overlap window in days, 0 to 30.

Returns: MintedKey for the new successor. Plaintext shown ONCE.

Raises:

  • AlterValueError — overlap_days out of [0, 30].
  • InsufficientScopeError — caller lacks keys:admin on the target.
  • BackendError — target not found / already revoked / is a dk key.
new_key = await app.keys.rotate(key_id=old.id, overlap_days=14)
# Deploy new_key.api_key; the old key remains valid for 14 more days

Revoke a key and every derived (dk) descendant in the same transaction. The cascade walks parent_key_id recursively but only follows dk children — rotation successors (same key_type) survive.

Default-protective: refuses to revoke the last active key on a managed agent unless force=True. The cascade is counted in the guard.

async def revoke(
*,
key_id: str | UUID,
force: bool = False,
) -> APIKeyInfo
ParameterTypeDefaultDescription
key_idstr | UUID—Key to revoke. Keyword-only.
forceboolFalseBypass the last-active-key guard.

Returns: APIKeyInfo with revoked_at populated.

Raises:

  • InsufficientScopeError — caller lacks keys:admin on the target.
  • BackendError — target not found / last-active without force / other failure.
revoked = await app.keys.revoke(key_id=leaked.id)
# emergency revoke of last active key:
revoked = await app.keys.revoke(key_id=last.id, force=True)

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.