Skip to content

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.
  • Agent runtime — workload-side primitives: me(), trace(), with_constraints().
  • keys.* — scoped sub-key lifecycle (derive, rotate, revoke).
# Operator side
created = 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 side
me = await agent.me()
async with agent.trace(run_id="run_42"):
await agent.request(...)
narrowed = agent.with_constraints(scopes=["grants:read"])
# 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,
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
ParameterTypeDefaultDescription
namestrAgent name. Lowercase, alphanumeric + dash/underscore. Must be unique among non-revoked agents in the app.
display_namestr | NoneNoneHuman-readable label.
typestr"agent""agent" (default) or "service".
scopesdict[str, list[str]] | NoneNonePer-provider scope allowlist.
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.

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 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.
limitint100Page size, 1-1000.
offsetint0Page 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) -> 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")

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
ParameterTypeDefaultDescription
agent_idstrAgent to update. Positional.
display_namestr | NoneNoneNew display name.
metadatadict | NoneNoneReplaces the metadata block atomically.
scopesdict[str, list[str]] | NoneNoneBroadening only. Pass the FULL desired allowlist. Dropping any provider key or any scope from an existing provider raises AgentScopeNarrowingNotSupportedError.
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, AgentScopeNarrowingNotSupportedError.

updated = await app.agents.update(
agent_id,
display_name="Customer Support Bot v2",
scopes={"slack": ["channels:read", "chat:write", "users:read"]},
)
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.

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; 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 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_idstrPositional.
key_idstrPositional.
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_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]
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 valuesFree-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 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 of method, provider_id, app_id, agent_id, api_key_id, environment, client_ip, resource_kind, and effect is always "deny". 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. 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
ParameterTypeDefaultDescription
scopeslist[str] | NoneNoneOptional non-empty list of scope strings to attenuate to.
ruleRequestRule | dict | NoneNoneOptional 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"},
},
)

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

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

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

Raises:

  • AlterValueErroroverlap_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 | UUIDKey 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)