Quickstart
This page walks one intent from end to end: read the policy in force, pin it, submit, read the verdict. That is the whole loop. Everything else in this reference is a detail of one of those four steps.
The Rust client, axorum-client, is the reference implementation: the crate
the wire contract is proven against. The TypeScript and Python clients ship
today, both at 0.3.0, and an MCP server, @axorum/mcp, ships beside them. All
three are held to the same conformance suite, run against a real service
rather than a mock, so the examples here are the shapes they ship with. See
SDKs.
Install
Install
cargo add axorum-client
Read the policy in force
An intent must name the policy it expects to be judged against. That claim is the pin, and it is not the agent's to invent: it is a statement about the service's state. So the loop starts by asking.
{"policy": null} is a valid answer, not a failure — "no policy is in force" is a true and useful fact about a ledger. Submitting against it is a 409 no_active_policy.
Read the policy in force
use axorum_client::AxorumClient;
let client = AxorumClient::builder("http://127.0.0.1:8080").build()?;
let policy = client.active_policy().await?; // Option<PolicyId>
Mint the transaction id
The transaction id is minted by the client, once, before any attempt — not by the service. It is the substrate's idempotency key: every re-pinned retry carries the same id, so an attempt that in fact landed replays its stored outcome instead of posting a second time.
Mint it before the first attempt, not inside the retry. An id minted per attempt is not an idempotency key; it is a way to double-post.
Submit the intent
The envelope carries what the agent knows: who is acting (agent and attestation), under which id, what it proposes in the books (entries), what it claims the authority to do (action), the external evidence it relies on, and its justification — plus the policy pin.
Debits and credits must balance per currency. The justification is hashed at the gateway: the hash goes on-ledger, the text stays with you.
The pin loop
If the pinned policy is not the one in force, the service answers 409 — stale_policy at the pre-check, or policy_pin_mismatch if the policy moved in the race between the check and the record — and nothing is written. Re-read the active policy, re-stamp the pin, resubmit. Three attempts, then stop: a service rotating policy faster than that is not one a client can chase, and looping forever would turn a hot rotation into a self-inflicted outage.
Every SDK does that loop for you. submit_pinned reads the policy in force, stamps the pin, and re-pins on a 409, carrying the same transaction id through every attempt.
Submit an intent
use std::collections::BTreeMap;
use axorum_client::{AxorumClient, IntentDraft};
use axorum_deontic::{ActionTerm, ActionType};
use axorum_substrate::{Amount, Entry, Side, TransactionId};
let client = AxorumClient::builder("http://127.0.0.1:8080").build()?;
// Minted once, by us, before any attempt — the idempotency key.
let transaction = TransactionId::from_uuid(uuid::Uuid::now_v7());
let draft = IntentDraft::new(
"agent://example.com/agent/clerk_01h455vb4pex5vsknk084sn02q",
attestation, // the PASETO v4.public capability token
transaction,
ActionTerm::new(ActionType::new("post")?, BTreeMap::new()),
)
.entries(vec![
Entry { account: cash, side: Side::Debit, amount: Amount::new(50_000, "USD") },
Entry { account: revenue, side: Side::Credit, amount: Amount::new(50_000, "USD") },
])
.justification("invoice 2214, net 30, within the standing purchase mandate");
// Reads the policy in force, stamps the pin, re-pins on a 409.
let outcome = client.submit_pinned(draft).await?;
Read the verdict
Branch on the verdict, never on the status code. A 200 means the intent reached the commit point and was judged. It does not mean the entries posted: posted is false exactly when the verdict is ForbiddenRejected, and that refusal was recorded.
The five verdicts:
- Name
Permitted- Type
- verdict
- Description
Allowed and posted. No open duties.
- Name
ForbiddenRejected- Type
- verdict
- Description
Refused. Nothing crossed the books,
postedisfalse, and it is still a 200.
- Name
ForbiddenRecorded- Type
- verdict
- Description
Posted under an authorized override. The violation is on the record, named.
- Name
ObligatedPending- Type
- verdict
- Description
Posted, and it opened an obligation.
- Name
ObligatedFulfilled- Type
- verdict
- Description
Posted, and it discharged an open one.
Branch on the verdict
// A refusal is an `Ok`, not an `Err`.
if outcome.posted {
println!("recorded and posted: {:?}", outcome.verdict);
} else {
println!("recorded as refused: {:?}", outcome.verdict);
}
for duty in &outcome.obligations_pending {
println!("now owed: {} by {}", duty.rule, duty.actor);
}
A recorded refusal — still a 200
{
"transaction": "txn_7w5b4trnc7b2ja027jyyb64395",
"verdict": "ForbiddenRejected",
"posted": false,
"policy": "pol_2s5479gmf7bhrsavd5awacb0fg",
"obligations_pending": [],
"justification_hash": "9f2cbe41e0a3c7d1f4b8e2a95c7d3f6018bb24e7c9a1d5f30e8b7c2a4d9f1e6b",
"provenance": { "tick": 4471 }
}
Next
- Intents — the envelope field by field, and every status it can answer with.
- Errors — the
snake_casecode vocabulary, and which codes are worth a retry. - Authentication —
agent://URIs and PASETO attestations.