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:

  • app.agents.* — operator CRUD for managed agents and per-agent key rotation.
  • Agent runtime — workload-side primitives: me(), trace(), with_constraints(), and onward delegate().
  • keys.* — scoped sub-key lifecycle (derive, rotate, revoke).
# 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"])
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
namestrAgent 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.
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={"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_idstrAgent 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.
expected_versionint | NoneNonePositive AgentInfo.version from the snapshot used to build scopes. Required whenever scopes is supplied.
grace_secondsint | NoneNoneNarrowing grace, 0604800. 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.

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". The agent’s credential calls — agent.request() and agent.proxy_request() — then raise AgentInactiveError; agent.me() keeps working so a paused workload can self-diagnose.
  • 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; 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 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"},
},
)

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)

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.