Python SDK
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().keys.*— scoped sub-key lifecycle (derive,rotate,revoke).
# Operator sidecreated = await app.agents.create(name="support-bot")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.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"])
# 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, display_name: str | None = None, type: str = "agent", scopes: dict[str, list[str]] | None = None, 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 | "agent" | "agent" (default) or "service". |
scopes | dict[str, list[str]] | None | None | Per-provider scope allowlist. |
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={"slack": ["channels:read", "chat: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 | Page size, 1-1000. |
offset | int | 0 | Page offset. |
Returns: AgentListResult.
page = await app.agents.list(limit=200)for a in page.agents: print(a.id, a.name, a.status)while page.has_more: page = await app.agents.list(limit=200, 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")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, 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 | Broadening only. Pass the FULL desired allowlist. Dropping any provider key or any scope from an existing provider raises AgentScopeNarrowingNotSupportedError. |
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, AgentScopeNarrowingNotSupportedError.
updated = await app.agents.update( agent_id, display_name="Customer Support Bot v2", scopes={"slack": ["channels:read", "chat:write", "users:read"]},)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.
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 one-off deny rule, evaluated server-side, that can only further restrict each request the returned client makes. It is deny-only — it can never widen access. Shape:{"rule_type": "json_match", "rule_body": {"when": {<attr>: str | list[str]}, "effect": "deny"}}, where<attr>is one ofmethod,provider_id,app_id,agent_id,api_key_id,environment,client_ip,resource_kind, andeffectis always"deny". 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. Endpoints cannot be scoped by a rule (the URL is not a matchable attribute). 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 one-off deny 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 one-off deny 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)