Skip to main content

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", attestation=paseto_token) 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)

Provenance and evaluation semantics

An outcome's provenance is either a recorded audit trail or the archived form. Recorded provenance has an optional semantics integer. It is omitted for pre-versioning records and names the evaluation law, not the outcome. master_data is also optional and is omitted when the policy did not consult replicated reference data.

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", attestation=paseto_token) as client:
    outcome = await client.submit_pinned(draft)
    if not outcome.posted:
        print(f"recorded as refused: {outcome.verdict}")

Writes carry their credential; so do reads

An intent authenticates inside the envelope: the draft names the agent:// URI and the PASETO attestation, and the service verifies both before it proposes anything. Reads are credentialed with the same token — there is no second credential to obtain. They present it as Authorization: Bearer …, and the service takes the reader's identity from that token's verified agent_uri claim and answers only within the party it is bound to.

So a client that reads holds the attestation:

with AxorumClient("http://127.0.0.1:8080", attestation=paseto_token) as client:
    owed = client.obligations()             # your party's duties
    record = client.transaction(txn_id)     # your party's record
    net = client.balance(account_id)
Method Attestation How
active_policy() not needed a public read: it discloses no party's data
submit_intent() / submit_pinned() / submit_pinned_from() not on the transport the envelope already carries it
transaction(id) required Authorization: Bearer …
obligations(actor=None) required Authorization: Bearer …
balance(account) required Authorization: Bearer …
usage(usage) required Authorization: Bearer …

The attestation is optional on the client: one that only submits needs none. A party-scoped read on a client that holds none raises MissingAttestationError locally, before anything reaches the wire — naming the read you attempted, rather than spending a round trip to be told 401 about a credential that was never sent.

obligations(actor) is an assertion, not a selector

?actor= used to select whose duties came back, so any party's duties were enumerable by anyone who could guess a name, with no credential at all. It does not select any more. Omit it and you are answered for the party your token names. Supply it — a party id (party_…) or a DSL party name — and the service checks that it resolves to that same party, answering 403 if it does not. It is there so a caller can be explicit about who it believes it is, and be told when it is wrong.

Rotating an expiring token

Attestations expire. with_attestation() hands back a client that reads as the bearer of a different one and shares the connection pool — rotating a token costs a string, not a reconnect. It is also how one process acts for several agents: each view sees only its own party's data.

fresh = client.with_attestation(reissued_token)   # same pool, new credential

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) — read GET /policies/active, stamp the pin, submit, and on a 409 re-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 the 409 tell 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, unknown_usage, substrate_rejected, capability_mapping, … Carries .status, .code, and the full .body.
MissingAttestationError — A party-scoped read on a client that holds no attestation. Raised before any request is sent; carries .operation. Build the client with attestation=…, or rotate one in with with_attestation().
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 attestation.

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, *, attestation=None, timeout=10.0, transport=None)
AsyncAxorumClient(base_url, *, attestation=None, timeout=10.0, transport=None)

client.with_attestation(attestation)              -> a new client, sharing the pool

client.active_policy()                            -> str | None          # no attestation
client.submit_intent(envelope)                    -> AgentOutcome        # token in the envelope
client.submit_pinned(draft)                       -> AgentOutcome        # token in the envelope
client.submit_pinned_from(draft, policy_id)       -> AgentOutcome        # token in the envelope
client.transaction(txn_id)                        -> TransactionRecord   # attested; opaque JSON
client.obligations(actor=None)                    -> ObligationsResponse # attested
client.balance(account_id)                        -> BalanceResponse     # attested

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.

Metered spend is one intent

An agent that consumes a metered resource carries the usage moves on the same draft as the journal legs. They are judged in the same commit, under the usage-balance conservation law 0 ≤ billed ≤ metered ≤ consumed:

draft = IntentDraft(
    agent=agent_uri,
    attestation=token,
    action=ActionTerm("post"),
    entries=[Entry.debit(cash, Amount(1000, "USD")),
             Entry.credit(revenue, Amount(1000, "USD"))],
    justification="one metered API call, billed on the spot",
    consume_usage=UsageMove(usage, Amount(1000, "USD")),   # you used it
    meter_usage=UsageMove(usage, Amount(1000, "USD")),     # it was measured
    bill_usage=UsageMove(usage, Amount(1000, "USD")),      # it was billed
)
outcome = client.submit_pinned(draft)

report = client.usage(usage)
print(report.text)          # the balance in compliance English, with exact figures

All three at once is the lawful case, not an edge case: the moves of one intent are validated together against the combined post-state. Any subset is fine too, and an absent move is omitted from the wire entirely, never sent as null.

Metering more than was consumed is phantom usage; billing more than was metered is billing with no meter basis. The ledger refuses both with a 422 substrate_rejected whose message is the compliance-English refusal with exact figures — and nothing posts, not even the journal legs that rode with the bad move. Under-recognition (billed < metered < consumed) is a lawful transient, not a fault to correct.

Usage reads are owner-scoped and fail-closed: a balance owned by another party, one opened with no owner, and one that never existed all answer the same 404 unknown_usage. The cases are deliberately indistinguishable, so that a usage id is never an existence oracle across a tenant boundary.

A usage balance is opened on the admin plane, which this SDK does not speak — an agent receives a usage id, it does not mint 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 read credentialer (authorize), 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

Metadata

Release files for axorum-client 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for axorum-client 0.5.0
File Size Uploaded
axorum_client-0.5.0.tar.gz 87.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for axorum-client 0.5.0
File Interpreter ABI Platform
axorum_client-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 130.4 kB

Release files / axorum_client-0.5.0.tar.gz

Download URL axorum_client-0.5.0.tar.gz
Size 87.5 kB
Tags Source
SHA-256 checksum
How to use checksums
b6cd27a948eb20c5725774a3ec151d084bff768260ff8bc1d4fe91d17211352b
BLAKE2b-256 checksum
How to use checksums
83695ffde813025c9383483c79ab38c5148fc0512819e6254f2ee57352117ab3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 6, 2026.

Transparency log

Release files / axorum_client-0.5.0-py3-none-any.whl

Download URL axorum_client-0.5.0-py3-none-any.whl
Size 42.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b3ac4e96f6d45223f9bbaf0ff0d76a8d239189993f3715cf63de1b203449ae47
BLAKE2b-256 checksum
How to use checksums
12589de2c27c70e7bd4a8c1376d0d5db1e3704a5477c900cff8801effe5edb32
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 6, 2026.

Transparency log

Release history Release notifications | RSS feed

1.1.0

2 release files

1.0.0

2 release files

This release

0.5.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page