aod-sdk-python
Agent on Demand Python SDK Skill
The aod-sdk Python package (import name aod) wraps every endpoint in docs/openapi.yaml with typed pydantic models, sync + async clients, and a typed SSE event stream. Package source lives at clients/python/ in this repo.
When This Skill Applies
Use this skill when:
- Writing Python that calls the AoD API via
from aod import Client/AsyncClient - Extending
clients/python/itself (new resources, new models, new stream helpers) - Debugging a traceback from
aod.errors.AodHTTPErroror its subclasses
For HTTP-level questions (route table, state machine, 409/422/429 semantics), defer to the agent-on-demand-api skill. For TypeScript, use aod-sdk-typescript.
Install & Configure
pip install aod-sdk
# or, in-tree development:
cd clients/python && uv pip install -e ".[dev]"
Client / AsyncClient read these in order of precedence:
- Constructor kwargs:
base_url=...,token=... - Env vars:
AOD_API_URL,AOD_API_TOKEN base_urldefault:http://localhost:8777.tokenis required — missing raisesValueError, not a 401.
Both clients are context managers. Use them as such — they own an httpx.Client/AsyncClient.
from aod import Client, AsyncClient
with Client(token="aod_...") as client:
...
async with AsyncClient() as client: # reads AOD_API_TOKEN from env
...
Resources Shape
Every client exposes three resource namespaces with identical method names on both sync and async variants:
| Namespace | Methods |
|---|---|
client.agents |
list(), create(...), get(id), update(id, version=..., **fields), archive(id), versions(id) |
client.environments |
list(), create(...), get(id), update(id, version=..., **fields), archive(id), delete(id), versions(id) |
client.sessions |
list(), create(agent_id=..., prompt=..., [environment_id=], [timeout=], [resources=]), get(id), prompt(id, prompt=...), turns(id), terminate(id), delete(id), stream(id, since=None) |
- Every method accepts
str | UUIDfor IDs; they're stringified on the wire. - Return types are pydantic models from
aod.models(Agent,Environment,Session,SessionAck,SessionTurn,AgentVersion,EnvironmentVersion). Unknown fields are ignored (extra="ignore"), so new server fields don't blow up old clients. sessions.createandsessions.promptandsessions.terminatereturnSessionAck— a trimmed payload, not a fullSession. Onlyid+statusare guaranteed;stream_url/environment_id/resources/current_turnare populated when the server provides them. Fetchclient.sessions.get(id)for the full record.
Streaming
client.sessions.stream(session_id, since=None) is a context manager that yields a StreamEvent iterator. The nesting matters — it owns an HTTP connection:
with client.sessions.stream(session_id) as events:
for event in events:
if event.type == "output":
print(event.extra["data"], end="")
elif event.type in ("exit", "error", "terminated", "stale"):
break
Async:
async with client.sessions.stream(session_id) as events:
async for event in events:
...
StreamEvent shape: type: StreamEventType + id: int | None + extra: dict[str, Any]. Every server-side field except type and id lands in extra. This is deliberate — the event schema is still evolving, and keeping the raw payload accessible avoids breakage.
Event types: start, turn_start, output, stage, exit, error, terminated, stale. Terminal types are exit/error/terminated/stale — break the loop when you see one.
To resume after a disconnect, pass since=<last event id>. since=None / omitted = full replay.
Errors
All non-2xx responses raise a typed subclass of AodError:
| Status | Exception | Common trigger |
|---|---|---|
| 401/3 | AuthError |
Missing/invalid token |
| 404 | NotFoundError |
Resource missing or not owned by the token's user |
| 409 | ConflictError |
Archive-already, terminal session, stale version, failed-resume |
| 422 | ValidationError |
Pydantic validation on the server — detail is a list |
| 429 | RateLimitError |
Concurrent-session quota. Has .limit and .active attrs |
| 5xx | ServerError |
Sprites upstream error or unhandled exception |
All share .status_code, .detail, .method, .url. detail is whatever the server sent — a string for most codes, a list of error dicts for 422 (Pydantic). Match with isinstance rather than on status_code.
from aod import ConflictError, RateLimitError
try:
client.agents.update(agent.id, version=agent.version, name="renamed")
except ConflictError as e:
# stale version — refetch and retry
latest = client.agents.get(agent.id)
client.agents.update(latest.id, version=latest.version, name="renamed")
except RateLimitError as e:
print(f"quota: {e.active}/{e.limit}")
Optimistic Concurrency Idiom
Agents and environments require version=<current> on every update. Stale → ConflictError. Standard pattern is read-then-write:
agent = client.agents.get(agent_id)
client.agents.update(agent.id, version=agent.version, system="...")
Merge/replace semantics match the HTTP API (covered in agent-on-demand-api):
metadatais merged per-key; empty string deletes the key.env_varsis fully replaced — re-send every key you want to keep.
Runtime-Scoped Pretty Printing
aod.pretty holds optional formatters that turn raw agent stdout into display lines. These are runtime-specific (runtime output formats are not part of the AoD API contract), so they live under a separate namespace and don't ship as part of the core Client.
from aod.pretty.claude import ClaudeFormatter
fmt = ClaudeFormatter()
with client.sessions.stream(session_id) as events:
for event in events:
for line in fmt.consume(event): # filters to output+stdout internally
print(line)
for line in fmt.flush(): # drain any half-buffered line
print(line)
Currently shipped: ClaudeFormatter (consumes the Claude CLI stream-json output format). Other runtimes have no formatter yet — iterate event.extra["data"] yourself for output events.
Common Gotchas
- Missing token is a
ValueErrorat construction, not anAuthError. You won't hit the network to discover the config is broken. Sessionresponse has noprompt.promptlives onSessionTurn— fetch it viaclient.sessions.turns(session_id).SessionAck.environment_idisNoneonprompt/terminateacks. It's only set oncreatebecause that's the only ack where the server knows it needs to echo it.- The stream context manager must be exited for the connection to close. Breaking out of the inner
foris fine; don't hold theeventsiterator past thewithblock. extra["data"]onoutputevents is a string.stream/turnalso live inextra. Don't assume a fixed schema — consultagent-on-demand-apifor the per-event payload shape.- Sync vs async symmetry is strict. Every
Clientmethod has anAsyncClientcounterpart with the same name and signature — exceptclose()→aclose()and context manager forms.
End-to-End Example
from aod import Client
with Client(token="aod_...") as client:
env = client.environments.create(
name="demo",
packages={"pip": ["requests"]},
env_vars={"DEMO": "1"},
networking={"type": "limited", "allowed_hosts": ["pypi.org"]},
)
agent = client.agents.create(
name="demo",
model="anthropic/claude-sonnet-4-6",
runtime="claude",
system="You are terse.",
environment_id=env.id,
)
ack = client.sessions.create(
agent_id=agent.id,
prompt="summarize README.md",
resources=[{"type": "github_repository", "url": "https://github.com/me/repo"}],
)
with client.sessions.stream(ack.id) as events:
for event in events:
if event.type == "output":
print(event.extra["data"], end="")
elif event.type in ("exit", "error", "terminated", "stale"):
break
final = client.sessions.get(ack.id)
print(f"status={final.status} exit_code={final.exit_code}")
Related Files
clients/python/src/aod/client.py—Client/AsyncClient; config resolutionclients/python/src/aod/resources/—agents.py,environments.py,sessions.pyclients/python/src/aod/models.py— pydantic models (Agent,Session,StreamEvent, etc.)clients/python/src/aod/errors.py— exception hierarchy +raise_for_statusclients/python/src/aod/stream.py— sync/async SSE iteratorsclients/python/src/aod/pretty/— runtime-scoped formatters (claude.py)clients/python/README.md— user-facing docs- Sibling skill
agent-on-demand-api— HTTP semantics, status codes, state machine
More from ravi-hq/agent-on-demand
aod-sdk-typescript
Use when writing TypeScript or JavaScript that calls the Agent on Demand API via `@ravi-hq/aod-sdk` — `new Client({...})`, `client.agents`/`environments`/`sessions`, `client.sessions.stream(...)`. Covers install, `AOD_API_URL`/`AOD_API_TOKEN` env fallbacks (Node only), the single async client (no sync variant), the `StreamHandle` async iterable with `.close()`, `AbortSignal` cancellation, typed `AodHTTPError` subclasses (`ConflictError`/`ValidationError`/`RateLimitError.limit`/`active`), and Node-vs-browser differences. Defers to the `agent-on-demand-api` skill for HTTP semantics, status codes, and state-machine edges.
1agent-on-demand-api
Use when driving the Agent on Demand REST API — creating agents, environments, or sessions; writing/maintaining `tests/e2e/`; adding new endpoints; or debugging 4xx responses. Covers auth, the route table, the `detail`-is-a-list quirk for 422, optimistic concurrency (`version`), agent-metadata-merge vs env_vars-full-replacement divergence, the session state machine (failed is terminal), session resources (GitHub repo clone), concurrent-session quota (429), SSE stream with stage events, and multi-turn session semantics. Canonical spec is `docs/openapi.yaml`.
1