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(), 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"])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. |
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.
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. |
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.
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". The agent’s credential calls —agent.request()andagent.proxy_request()— then raiseAgentInactiveError;agent.me()keeps working so a paused workload can self-diagnose.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; the SDK surfaces a deprecation warning when a deprecated key is used 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_ak_…")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]| 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"}, },)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.
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)