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:
app.agents.*— operator CRUD for managed agents and per-agent key rotation.Agentruntime — workload-side primitives:me(),trace(),with_constraints(),create_managed_secret_connect_session()(ask a user to delegate a managed secret to this agent), and onwarddelegate().keys.*— scoped sub-key lifecycle (derive,rotate,revoke).
# Operator sidecreated = 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 sideme = 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 lifecyclederived = 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)app.agents namespace
Section titled “app.agents namespace”create
Section titled “create”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| Parameter | Type | Default | Description |
|---|---|---|---|
name | str | — | Agent name. Lowercase, alphanumeric + dash/underscore. Must be unique among non-revoked agents in the app. |
display_name | str | None | None | Human-readable label. |
type | str | None | "agent" | "agent" (default) or "service". Explicit None normalizes to "agent". |
scopes | dict[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. |
metadata | dict | None | None | Free-form metadata. |
policy | dict | None | None | Validated policy block (HITL config + policy attributes). |
idempotency_key | str | None | None | Optional 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 recoverableagent_id = str(result.id)async def list( *, include_revoked: bool = False, limit: int = 100, offset: int = 0,) -> AgentListResult| Parameter | Type | Default | Description |
|---|---|---|---|
include_revoked | bool | False | Include tombstoned agents. |
limit | int | 100 | Requested page size, 1-1000. The server returns at most 10 agents per page. |
offset | int | 0 | Page 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) -> AgentInfoRaises: AlterValueError, AgentNotFoundError.
get_by_name
Section titled “get_by_name”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 | Noneexisting = 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
Section titled “update”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| Parameter | Type | Default | Description |
|---|---|---|---|
agent_id | str | — | Agent to update. Positional. |
display_name | str | None | None | New display name. |
metadata | dict | None | None | Replaces the metadata block atomically. |
scopes | dict[str, list[str]] | None | None | Full 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_version | int | None | None | Positive AgentInfo.version from the snapshot used to build scopes. Required whenever scopes is supplied. |
grace_seconds | int | None | None | Narrowing grace, 0–604800. Requires scopes; 0 applies immediately. Omit to use the deployment default (24 hours). |
policy | dict | None | None | Replaces 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.
suspend / resume
Section titled “suspend / resume”Pause and later restore an agent without revoking its identity.
async def suspend(agent_id: str) -> AgentInfoasync def resume(agent_id: str) -> AgentInfosuspend()flipsstatusto"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 raisesAgentInactiveError: 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()flipsstatusback 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)delete
Section titled “delete”async def delete(agent_id: str) -> AgentInfoReturns: the updated AgentInfo with status="revoked".
Raises: AlterValueError, AgentNotFoundError.
mint_key
Section titled “mint_key”Mint a subsequent API key for an existing agent. Plaintext returned ONCE.
async def mint_key(agent_id: str) -> AgentKeyMintResultReturns: AgentKeyMintResult.
Raises: AlterValueError, AgentNotFoundError, AgentCannotMintSubagentsError, AgentKeyLimitError (the agent is at its active-key cap — revoke an existing key to free a slot).
list_keys
Section titled “list_keys”Every key for the agent, ordered by created_at ascending. Plaintext is never included.
async def list_keys(agent_id: str) -> AgentKeyListdeprecate_key / undeprecate_key
Section titled “deprecate_key / undeprecate_key”async def deprecate_key(agent_id: str, key_id: str) -> AgentKeyasync def undeprecate_key(agent_id: str, key_id: str) -> AgentKeyMark 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_key
Section titled “revoke_key”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| Parameter | Type | Default | Description |
|---|---|---|---|
agent_id | str | — | Positional. |
key_id | str | — | Positional. |
force | bool | False | Bypass the last-active-key guard. |
Raises: AlterValueError, AgentNotFoundError, KeyNotFoundError, LastActiveKeyError.
Agent runtime
Section titled “Agent runtime”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() -> AgentInfoReturns: 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.createinfo = 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]| Parameter | Type | Default | Description |
|---|---|---|---|
run_id | str | None | None | Correlation id for the run (e.g. LangGraph run id). Inherited from an outer trace() block if omitted. |
thread_id | str | None | None | Conversation thread id. Inherited from an outer block if omitted. |
parent | str | None | ... | ... (sentinel) | ... (default): ambient detection of an outer trace() block from a different agent. None: explicit “no parent”. str: explicit parent agent name. |
**metadata | str 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)with_constraints
Section titled “with_constraints”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:
scopesnarrows 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 400constraint_not_narrowing.ruleattaches 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 includejson_matchdeny rules,require_approvalHITL rules, andcontent_matchrules built withcontent_match_rule(...). A raw-token retrieval carries no request method/URL, somethod-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| Parameter | Type | Default | Description |
|---|---|---|---|
scopes | list[str] | None | None | Optional non-empty list of scope strings to attenuate to. |
rule | RequestRule | dict | None | None | Optional 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"}, },)create_managed_secret_connect_session
Section titled “create_managed_secret_connect_session”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| Parameter | Type | Default | Description |
|---|---|---|---|
template_slug | str | — | Canonical template slug (e.g. "provider-api-key"). Kebab-case required. |
user_token | str | — | IDP JWT identifying the user being asked. Required. |
requested_ttl_seconds | int | None | None | Caller-suggested TTL, capped as on the App method. |
allowed_origin | str | None | None | Popup-flow postMessage origin: a single canonical origin, as on the App method (otherwise HTTP 400 invalid_origin). |
return_url | str | None | None | Mobile-redirect return URL. |
allow_user_policy_rules | bool | True | Whether the user may add narrowing-only usage limits during approval. |
delegable | bool | False | Ask 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
Agentbuilt from the agent’s own key (Agent(api_key=...)) sends no agent id; the backend targets the key’s agent. - An
AgentfromApp.get_agent()sends its agent id asdelegated_agent_idunder the application’s key, so the request is exactly the App call for that agent. - An
Agentconstructed directly from an application key, or from an agent-typedalter_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 422delegated_agent_id_required(BackendError). UseApp.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 osfrom 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.keys namespace
Section titled “keys namespace”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).
derive
Section titled “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| Parameter | Type | Default | Description |
|---|---|---|---|
scopes | list[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_in | int | — | Lifetime in seconds. Positive integer. Capped at the org policy ceiling and the caller’s remaining TTL. |
cidr_allowlist | list[str] | None | None | Per-key IP allowlist. Subset of caller’s. Inherits caller’s value when omitted. |
name | str | None | None | Human-readable label. Defaults to derived-YYYYMMDD-HHMMSS on the backend. |
metadata | dict | None | None | Free-form metadata stored on the key. |
Returns: MintedKey. The plaintext api_key is returned ONCE.
Raises:
AlterValueError— empty scopes, non-string scope entries, non-positiveexpires_in,keys:derivein scopes.InsufficientScopeError— caller lackskeys:derive.BackendError— scope-not-subset (permission boundary) or other lifecycle failure.
import osfrom 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 ONCErotate
Section titled “rotate”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| Parameter | Type | Default | Description |
|---|---|---|---|
key_id | str | UUID | — | Key to rotate. Keyword-only. |
overlap_days | int | 7 | Overlap window in days, 0 to 30. |
Returns: MintedKey for the new successor. Plaintext shown ONCE.
Raises:
AlterValueError—overlap_daysout of[0, 30].InsufficientScopeError— caller lackskeys:adminon the target.BackendError— target not found / already revoked / is adkkey.
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 daysrevoke
Section titled “revoke”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| Parameter | Type | Default | Description |
|---|---|---|---|
key_id | str | UUID | — | Key to revoke. Keyword-only. |
force | bool | False | Bypass the last-active-key guard. |
Returns: APIKeyInfo with revoked_at populated.
Raises:
InsufficientScopeError— caller lackskeys:adminon the target.BackendError— target not found / last-active withoutforce/ 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)