The official Python client for the Axorum agent plane.
Project description
axorum-client
The official Python client for the Axorum agent plane — the wire an autonomous agent speaks to submit intents to a deontic financial-control ledger and read what the ledger did about them.
pip install axorum-client # or: uv add axorum-client
Requires Python 3.11+. The only runtime dependency is httpx.
The one rule: a refusal is a value, not an exception
Axorum's job is to judge an intent against the policy in force. When the policy
forbids the action, the intent still reaches the commit point, is still
judged, and the refusal is still recorded — and that record is the
product. It comes back as a 200, as an ordinary AgentOutcome with
posted == False.
So this client returns it. It does not raise.
outcome = client.submit_pinned(draft)
if outcome.posted:
print("permitted, and the entries posted")
else:
# NOT a failure. The ledger recorded a refusal, which is what you asked it to do.
print(f"refused, and the refusal is on the ledger: {outcome.verdict}")
An exception from this client means the ledger never got to answer at all: the caller was not who they claimed, the pin did not hold, the cluster could not confirm, the wire broke. A client that raised on a refusal would be throwing away the very artifact the ledger exists to produce.
Quickstart
from axorum import ActionTerm, Amount, AxorumClient, Entry, IntentDraft
draft = IntentDraft(
agent="agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q",
attestation=paseto_token, # the PASETO v4.public capability attestation
action=ActionTerm("post"),
entries=[
Entry.debit(cash_account, Amount(minor=1000, currency="USD")),
Entry.credit(revenue_account, Amount(minor=1000, currency="USD")),
],
justification="settling invoice 42",
)
with AxorumClient("http://127.0.0.1:8080") as client:
outcome = client.submit_pinned(draft)
print(outcome.verdict) # Verdict.PERMITTED
print(outcome.posted) # True
print(outcome.policy) # the policy it was judged under
print(outcome.transaction) # the id you minted, echoed back
…and the same call, refused
Point the same draft at a ledger whose policy forbids post, and nothing about
your code changes. You do not catch anything. You read the outcome:
with AxorumClient("http://127.0.0.1:8080") as client:
outcome = client.submit_intent(draft.pin(policy_id))
assert outcome.posted is False
assert outcome.verdict is Verdict.FORBIDDEN_REJECTED
# The refusal is on the ledger, and reads back like any other record.
record = client.transaction(outcome.transaction)
Async
The same client, awaited. Same method names, same arguments, same semantics.
from axorum import AsyncAxorumClient
async with AsyncAxorumClient("http://127.0.0.1:8080") as client:
outcome = await client.submit_pinned(draft)
if not outcome.posted:
print(f"recorded as refused: {outcome.verdict}")
Policy pinning, and why submit_pinned exists
Every intent names the policy the agent believes is in force. If that pin is
stale, the service refuses with a 409 and nothing is written. The recovery
is always the same: re-pin against the policy now in force and submit again.
That is the loop every correct agent writes, so it ships here instead:
submit_pinned(draft)— readGET /policies/active, stamp the pin, submit, and on a409re-pin against the policy the rejection itself named and go again. Bounded at 3 pin attempts.submit_pinned_from(draft, policy_id)— the same, but starting from a policy you already believe is in force. This is what a long-running agent wants: hold the last policy you saw, submit straight against it, and let the409tell you when your belief went stale.submit_intent(envelope)— no recovery, no re-reads. You pinned it; you own it.
Retrying is safe for one specific reason: the transaction id is minted once, on
the draft, before the first attempt, and it is the substrate's idempotency key.
Every re-pinned attempt carries the same id, so an attempt that in fact landed
replays its stored outcome. The loop cannot double-post. (IntentDraft mints one
for you from a UUID v4; supply your own if you have one.)
The exception taxonomy
Everything below inherits from AxorumError. None of them is a refusal.
| Exception | Status / code | What it means, and what to do |
|---|---|---|
StalePolicyError |
409 stale_policy / policy_pin_mismatch |
The pin did not hold and nothing was written. Carries .pinned and .active — re-pin against .active. submit_pinned does this for you. .requires_repin is True. |
NoActivePolicyError |
409 no_active_policy |
No policy is in force at all. Re-pinning cannot help; an operator has to activate one. |
UnknownAgentError |
401 unknown_agent |
The agent:// URI is not bound to a party. Carries .uri. |
AttestationError |
403 attestation |
The PASETO attestation was rejected — bad signature, expired, wrong audience, untrusted issuer. |
NotPrimaryError |
421 not_primary |
A cluster mutation reached a follower. Nothing was written. Carries .primary_client_addr (which may be None). |
CommitUnavailableError |
503 commit_unavailable |
The commit could not be confirmed. It may or may not have landed. .is_retriable is True — retrying the same transaction id is safe, or read it back with client.transaction(id). |
ApiError |
any documented code | A documented error with no distinct recovery: unknown_account, unknown_transaction, substrate_rejected, capability_mapping, … Carries .status, .code, and the full .body. |
TransportError |
— | The service could not be reached. .is_retriable is True. |
UnexpectedResponseError |
any | A non-2xx whose body is not an agent-plane error body — a proxy, a load balancer. The bytes are kept verbatim in .body. |
DecodeError |
any | The service and this client disagree about the wire. Bytes kept verbatim. |
ConfigError |
— | The client could not be built: a malformed base URL, an empty token. |
Two convenience properties on every error say what to do next:
try:
outcome = client.submit_intent(envelope)
except AxorumError as failure:
if failure.requires_repin:
... # re-pin against failure.active and resubmit
elif failure.is_retriable:
... # re-send the IDENTICAL request — safe, the txn id is the idempotency key
else:
raise
This client never auto-follows a 421
In cluster mode, a mutation that reaches a non-primary replica is refused 421 not_primary, with the primary's address in the body. This client will not
silently re-send there. It raises NotPrimaryError and hands you
.primary_client_addr.
That is deliberate. Following the redirect is an operational decision: a client that auto-follows hides a topology change from the operator who most needs to see it — a follower answering your writes means something moved, and you should learn that from your client, not from a graph three days later. If you want to follow it, follow it on purpose:
try:
outcome = client.submit_intent(envelope)
except NotPrimaryError as failure:
if failure.primary_client_addr is None:
raise # the follower doesn't know either
with AxorumClient(f"http://{failure.primary_client_addr}") as primary:
outcome = primary.submit_intent(envelope) # same txn id — cannot double-post
API surface
AxorumClient(base_url, *, bearer_token=None, timeout=10.0, transport=None)
AsyncAxorumClient(base_url, *, bearer_token=None, timeout=10.0, transport=None)
client.active_policy() -> str | None
client.submit_intent(envelope) -> AgentOutcome
client.submit_pinned(draft) -> AgentOutcome
client.submit_pinned_from(draft, policy_id) -> AgentOutcome
client.transaction(txn_id) -> TransactionRecord # opaque JSON
client.obligations(actor) -> ObligationsResponse
client.balance(account_id) -> BalanceResponse
active_policy() returning None is not an error — "no policy is in force"
is a true and useful fact about a ledger, not a failure to report one.
Every request carries an auto-generated x-request-id (UUID v4) so you can find
it in the service's logs. Pass your own through if you are already carrying a
correlation id.
Pure protocol core
axorum.protocol holds every request builder, every response decoder, the error
mapper (error_from(status, body)), and the policy-pin loop (pin_flow) — all
pure functions of their inputs, with no I/O anywhere. The sync and async clients
are thin shells over them, which is why they cannot drift, and why the protocol
is exhaustively testable without a socket.
Development
uv sync
uv run ruff check . && uv run ruff format --check .
uv run mypy --strict src/
uv run pytest # unit tests
uv run pytest -m conformance # against a real axorum-serve
License
Apache-2.0
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file axorum_client-0.1.0.tar.gz.
File metadata
- Download URL: axorum_client-0.1.0.tar.gz
- Upload date:
- Size: 64.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8ef16d395f9b975ec580c96cc9bae8cbaae4fe10b0ac3227f8bd733bab6e613b
|
|
| MD5 |
31f40ceaa9ca873fa03fe0ae666caf13
|
|
| BLAKE2b-256 |
1fe7bb3a755f24af7411c0c5f1aaf87d34956863338e69381fb4da9e3c14ea8b
|
File details
Details for the file axorum_client-0.1.0-py3-none-any.whl.
File metadata
- Download URL: axorum_client-0.1.0-py3-none-any.whl
- Upload date:
- Size: 32.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
844b0c429e529015240922fff95e395909bce89e77c9fa3cad8e19a3d9cbd60b
|
|
| MD5 |
298a310b8bd78ad80fd847406784330c
|
|
| BLAKE2b-256 |
2065a475939dee80fcb7b16145e8ca27f433ef16a3c2ceec2000394579fa8a24
|