Mandate and governance
The four commands that mint spend authority, verify a presentation token offline, issue spend cards, and read KYC settlement.
Four executable commands govern what an agent is allowed to spend and on what instrument: codespar mandate create mints the authority, codespar mandate verify checks a presentation token a counterparty handed you, codespar issue creates and controls the card the authority is spent on, and codespar verification-status reads where an asynchronous KYC inquiry landed. mandate is a command group with those two leaves; issue and verification-status are top level.
Two commands that do not exist
codespar mandate show and codespar mandate revoke are not in the shipped binary. Measured with codespar mandate --help, which lists exactly create, verify and help and nothing else. There is no CLI path to read a mandate back or to revoke one; the lifecycle beyond creation is described in Mandates.
Shared behaviour
These root options are declared on the program, not on the subcommands, and apply to all four. Commander accepts --json in either position: codespar --json mandate verify <token> and codespar issue --input '{...}' --json were both measured and both emitted JSON.
| Option | Type | Required | What it does |
|---|---|---|---|
--api-key <key> | string | no | API key for this invocation. Beats the environment and the config file. |
--base-url <url> | string | no | API host. Falls back to https://api.codespar.dev when nothing sets it. |
--project <id> | string | no | Sends x-codespar-project on requests the CLI's own HTTP client makes. |
--json | boolean | no | Machine-readable output on stdout instead of the human block. |
-v, --version | boolean | no | Prints the version. The short flag is -v, not Commander's default -V. |
Credentials resolve flag first, then environment (CODESPAR_API_KEY, CODESPAR_PROJECT, CODESPAR_BASE_URL), then ~/.codespar/config.json, which login writes with mode 0600. Three of the four commands refuse immediately without a key; mandate verify is the exception and is documented below.
Channels are split by design. Tables, key/value blocks and JSON go to stdout; the ✓, ℹ and ⚠ lines go to stderr. In human mode codespar issue and codespar verification-status write nothing at all to stdout, which is worth knowing before you pipe them.
Exit codes
Measured on these four commands, with the command that produced each.
| Exit | What is printed | Measured on |
|---|---|---|
0 | the result | every success, and any --help |
1 | ✗ <message> on stderr, no stack | argument and credential refusals in all four commands |
1 | error: <message> on stderr, no ✗ | Commander's own refusals: a missing required argument or option |
1 | the full report, then a note on stderr | mandate verify when the token does not verify |
2 | ✗ internal error: and a stack trace | issue and verification-status when the call fails |
The 2 is not a status code for "the operation failed". It is the CLI's bucket for an error it did not recognise, and the two commands here that go through a typed SDK wrapper land in it on ordinary failures, because that wrapper throws a plain Error rather than the CLI's own error type. Measured with one stub answering 403 insufficient_scope: codespar mandate create exited 1 with ✗ POST /v1/consents/init → 403: API key is missing the cards:write scope., while codespar issue exited 2 with a stack trace for the same refusal. A script that treats 2 as a bug and 1 as a rejection will read a scope problem as a crash.
- your backendMint the consent
- POST /v1/consents/init
- agent, purpose, caps, payees, TTL
- returns a one-shot token
- the consumerSign at the hosted page
- /consent/{token}
- picks the rail, reviews the terms
- submits once (replay-guarded)
- CodeSparSign the mandate
- per-consumer secret in the vault
- HMAC over the canonical fields
- returns { mandate, signature }
- your agentSpend under it
- codespar_pay, or POST .../spend
- verify, hold, debit, seal receipt
- revoke or pause at any time
codespar mandate create collapses the first two stops into one call: it mints the consent token and then submits the consumer leg itself, so the hosted consent page is skipped and the terminal stands in for the consumer. The third stop stays on the server, which is why the command comes back with a mandate_id and never with a key. codespar mandate verify checks those signatures again from outside, codespar issue creates the card the fourth stop spends on, and the mandate id goes to codespar spend. The rest of that stop is out of reach here: with no codespar mandate revoke in the binary, pausing or revoking an authority happens off the CLI.
codespar mandate create
Runs the two-leg directed-pay consent flow from your terminal: it mints a one-shot consent token carrying the intent (purpose, caps, currency or slots, payee allowlist), then submits the consumer side against that token, which is what provisions the funding source and signs the mandate. It prints the mandate id you hand to codespar spend.
https://api.codespar.dev/v1/consents/inithttps://api.codespar.dev/v1/consents/{token}/submitThe first leg carries your API key; the token in the second leg's URL is the authentication, so the bearer header there is ignored. Two notes on those routes, both measured against the committed OpenAPI snapshot. POST /v1/consents/init is in the published document and marked deprecated there, as an alias of POST /v1/consents kept for two releases; the CLI still calls the alias. POST /v1/consents/{token}/submit is not in the published document (ent#1186): it is the leg the consumer's browser is meant to call, and this command calls it from the tenant's terminal. See Mandates for why that leg is kept out of the client surface.
Flags
| Flag | Type | Required | What it does |
|---|---|---|---|
-c, --consumer <id> | string | yes | Consumer id. Sent as consumer_id on the submit leg. |
--agent <id> | string | yes | Agent the mandate authorizes. Sent as agent_id on the init leg. |
--purpose <text> | string | yes | Human purpose, signed into the mandate. Becomes intent.purpose. |
-p, --payee <list> | string | yes | Allowlisted payees, comma-separated. Each entry is trimmed and empties dropped; the result becomes intent.merchant_allowlist. |
--cap <minor> | integer | when no --slot | Total cap in minor units. Becomes intent.cap_minor. |
--per-tx-cap <minor> | integer | when no --slot | Per-transaction cap in minor units. Becomes intent.per_tx_cap_minor. |
--slot <spec> | CURRENCY:RAIL:CAP:PER_TX_CAP | no | Repeatable. Builds intent.slots, one entry per currency. Default is an empty list. |
--currency <code> | string | no | Default USDC. The single-currency path only. |
--rail <rail> | string | no | Default usdc-onchain. Sent on the submit body. |
--ttl <seconds> | integer | no | Default 86400. Becomes intent.mandate_ttl_seconds. |
--pin-kind <kind> | string | no | Default merchant-id. Becomes intent.merchant_pin_kind. |
--provider-token <token> | string | no | Sent on the submit body. When omitted the CLI sends the literal <rail>:<consumer>. |
Two rules are worth stating in prose because they are not visible in the flag list. --slot wins. When at least one --slot is given, --cap, --per-tx-cap and --currency are ignored, and the top of the intent mirrors the first slot: its cap, its per-transaction cap and its currency. The rail on the submit leg is not the slot's rail. Each slot carries its own rail inside intent.slots; the --rail value only satisfies the submit schema, so leaving the default in place on a multi-slot mandate is expected and was measured doing exactly that.
--pin-kind and --currency are checked against the vocabularies the consent contract declares, so a typo fails at your terminal instead of at the end of a consent ceremony:
✗ --pin-kind expects one of pix-key, merchant-id, mcc, got "inventado".
✗ --currency expects one of BRL, USD, MXN, COP, ARS, USDC, BRLA, got "EUR".Example
codespar mandate create \
--consumer con_9x1 \
--agent agt_7fa21c \
--purpose "Compras do mes" \
--payee "loja@empresa.com.br" \
--slot BRL:pix-celcoin:50000:10000 \
--slot USDC:usdc-onchain:100:25mandate cm_4f19ab
consumer con_9x1
agent agt_7fa21c
purpose Compras do mes
allowlist loja@empresa.com.br
slots BRL/pix-celcoin cap 50000 (per-tx 10000) | USDC/usdc-onchain cap 100 (per-tx 25)✓ multi-slot mandate created
ℹ One signature, 2 currencies — caps are per-currency (no FX).
See the wallet: codespar wallet con_9x1
Spend: codespar spend --mandate cm_4f19ab --payee loja@empresa.com.br --amount 1 --agent agt_7fa21cThe caps in that call are minor units, per currency, with no conversion between slots: 50000 and 10000 are R$ 500,00 total and R$ 100,00 per transaction on the BRL slot, 100 and 25 are the USDC slot's own numbers.
With the single-currency form (--cap 10000 --per-tx-cap 2500, no --slot), the block changes shape: per-tx cap, total cap and rail rows replace the slots row, and the values are labelled with --currency, which defaults to USDC.
With --json, stdout carries the submit response and nothing else, and stderr is empty. The init response is not printed in either mode.
{
"mandate_id": "cm_4f19ab",
"consumer_id": "con_9x1",
"status": "active",
"expires_at": "2026-09-13T18:00:00Z"
}The one field the CLI reads from the submit leg is mandate_id; everything else in the body is passed through to --json untouched. When mandate_id is absent the human output prints (unknown) in its place.
Refusals
Every row below was run. Validation of caps, TTL and slots happens before any request, so a malformed call costs no round trip.
| Trigger | What is printed on stderr | Exit |
|---|---|---|
| no API key resolvable | ✗ Not logged in. Run `codespar login` or set CODESPAR_API_KEY. | 1 |
| a missing required flag | error: required option '--agent <id>' not specified | 1 |
--payee empty after trimming | ✗ --payee is required (allowlisted payee: x402 URL, EVM address, or Pix key; comma-separated for several). | 1 |
no --cap and no --slot | ✗ --cap must be a positive integer in minor units (or use --slot for a multi-currency mandate). | 1 |
--ttl 0 or a non-integer | ✗ --ttl must be a positive integer (seconds). | 1 |
a --slot without four fields | ✗ --slot must be CURRENCY:RAIL:CAP:PER_TX_CAP (e.g. USDC:usdc-onchain:100:100). Got: BRL:pix:500 | 1 |
a slot cap of 0 or a non-integer | ✗ --slot BRL:pix-celcoin:0:10: cap must be a positive integer (minor units). | 1 |
| the API refuses either leg | ✗ POST /v1/consents/init → 403: API key is missing the cards:write scope. | 1 |
The last row is the general shape for an HTTP failure on this command: verb, path, status, then the message the API sent. A scope refusal and a validation refusal are indistinguishable by exit code; the text is what separates them.
codespar mandate verify
Decodes a base64url presentation token, rebuilds the canonical signing string from the fields it carries, and checks the Ed25519 signatures against keys you supply or against the did:web documents it resolves. It is the only command on this page that runs with no API key: it loads the config for a base URL and never calls requireApiKey. Measured with CODESPAR_API_KEY emptied, and it printed the full report.
There is no REST route behind the offline path. In network mode it fetches DID documents, described below.
Arguments and flags
| Name | Type | Required | What it does |
|---|---|---|---|
<token> | string | yes | The presentation token, base64url. |
--agent-pubkey <hex> | string | no | Raw 32-byte Ed25519 agent key, hex. Its presence forces offline mode. |
--issuer-pubkey <hex> | string | no | Raw 32-byte Ed25519 issuer key, hex. Its presence forces offline mode. |
--issuer-did <did> | string | no | Issuer DID for network mode. Without it the issuer DID is derived from the host segment of the agent DID. |
Either pubkey flag switches the whole command to offline: zero network calls, both signatures checked against what you passed. With neither flag it runs in network mode and resolves keys from DID documents. There is no flag that forces network mode while a key is supplied.
Keys are 64 hex characters, a leading 0x is tolerated and case is ignored. Anything else is refused before the token is touched.
What counts as verified
A token passes when at least one carried signature verified and none failed. In offline mode a signature whose key you did not supply is reported skipped, which neither passes nor fails on its own: a token with a good agent_sig and no --issuer-pubkey exits 0 with the issuer line marked skipped. That was measured, and it is the behaviour to read carefully before treating this command as a gate.
Expiry is reported, not enforced. A token whose expires_at was in the past, with both signatures good, printed [expired] next to the timestamp and still exited 0 with verified. If expiry matters to your check, read expired out of the --json body yourself.
The principal's document is never printed. Human output prints principal_kyc present or absent; --json emits principal_kyc_ref_present as a boolean. The CPF or CNPJ reference itself is redacted in both.
Example
codespar mandate verify "$TOKEN" \
--agent-pubkey 2abe98d728d767007bb7329e9d1f1d7f107b2f1288457291f300ece461349b87 \
--issuer-pubkey 272b1badc6c62899dc5a1a1c25f09b06ab57db69e31e938ac30791c7bee9f17asignatures
agent_sig ✓ verified kid did:web:id.codespar.dev:acme:shopper#1 via flag
issuer_sig ✓ verified via flag
mandate
id mnd_3xq8w2
agent_id agt_7fa21c
agent_did did:web:id.codespar.dev:acme:shopper
kid did:web:id.codespar.dev:acme:shopper#1
type payment
amount 50000 BRL
max_amount 10000 BRL
purposes groceries, delivery
principal_kyc present
expires_at 1789913600 (2026-09-20T14:13:20.000Z)
format v3✓ mandate token verified (offline mode)Corrupt agent_sig in that token and the same call exits 1: the report still prints in full, the agent line reads ✗ failed, the issuer line still reads ✓ verified, and stderr adds ⚠ mandate token NOT verified (offline mode) plus a line saying a carried signature failed or could not be checked. That is the shape to key a gate on, because a single bad signature is enough to fail the token even when the other one verified.
The --json body of the passing run:
{
"verified": true,
"mode": "offline",
"format_version": 3,
"signatures": {
"agent_sig": { "present": true, "status": "verified", "kid": "did:web:id.codespar.dev:acme:shopper#1", "source": "flag", "detail": null },
"issuer_sig": { "present": true, "status": "verified", "kid": null, "source": "flag", "detail": null }
},
"mandate": {
"id": "mnd_3xq8w2",
"agent_id": "agt_7fa21c",
"agent_did": "did:web:id.codespar.dev:acme:shopper",
"kid": "did:web:id.codespar.dev:acme:shopper#1",
"type": "payment",
"amount": "50000",
"currency": "BRL",
"max_amount": "10000",
"parent_id": null,
"denomination": null,
"purposes": ["groceries", "delivery"],
"principal_kyc_ref_present": true,
"expires_at": 1789913600,
"expires_at_iso": "2026-09-20T14:13:20.000Z",
"expired": false,
"format_version": 3
}
}A token that fails to decode answers in the same channel it would have used: --json prints { "verified": false, "error": "mandate_format_unsupported" } and exits 1, while the human mode throws the message as an ordinary refusal.
Network mode
Without a pubkey flag the command resolves keys over the network, trying two URLs in order and stopping at the first that yields an Ed25519 key: the standard did:web mapping (https://<host>/.well-known/did.json, or https://<host>/<path>/did.json when the DID carries path segments), then the API fallback at <base-url>/v1/agents/<did>/did.json. Only OKP / Ed25519 entries of exactly 32 bytes are taken from a document's verificationMethod; everything else is skipped. When the token names a key id, keys matching it are tried first. A document that cannot be fetched, or that carries no usable key, is a failure in network mode, not a skip. Each fetch has its own 15 second budget.
Network mode reaches out to resolve the DID document; the three shapes above are the offline path, which needs nothing but the token.
Refusals
| Trigger | What is printed on stderr | Exit |
|---|---|---|
| no token | error: missing required argument 'token' | 1 |
| a token that is not base64url JSON | ✗ cannot decode token — not a valid base64url mandate presentation token. | 1 |
format_version below 2 | ✗ unsupported mandate format (need format_version >= 2). | 1 |
| a key that is not 64 hex characters | ✗ --agent-pubkey must be 64 hex chars (a raw 32-byte Ed25519 key). | 1 |
| a signature failed or could not be checked | the report, then ⚠ mandate token NOT verified | 1 |
codespar issue
Opens a throwaway session with no servers, calls the codespar_issue meta-tool through it, and closes the session in a finally whether the call succeeded or not. There is no --server: the meta-tool router picks the issuing rail.
https://api.codespar.dev/v1/sessions/{id}/executeResolves `tool` as a CodeSpar meta-tool first, then as a catalog tool
Measured on the stub, one invocation is three requests: POST /v1/sessions with body {"servers":[],"user_id":"cli-user"}, then the execute above with {"tool":"codespar_issue","input":{...}}, then DELETE /v1/sessions/<id>.
Flags
| Flag | Type | Required | What it does |
|---|---|---|---|
-i, --input <json> | string | one of the two | The meta-tool arguments as a JSON object. |
-f, --input-file <path> | string | one of the two | The same object read from a file. |
-u, --user <id> | string | no | Session user. Defaults to the literal cli-user. |
--input and --input-file are mutually exclusive, and one of them is mandatory. The payload has to parse as a JSON object: an array or a scalar is refused by name.
The arguments the CLI checks
This table is the subset the CLI validates locally, before opening a session. It is not the schema of the meta-tool, which has more fields and is the authority on all of them; for that, read codespar_issue.
| Field | Type | Required | What the CLI enforces |
|---|---|---|---|
action | string | always | One of card-virtual, card-physical, card-control, card-get. |
cardholder_id | string | for card-virtual and card-physical | Presence only. |
program_id | string | for card-virtual and card-physical | Presence only. |
shipping_address | object | for card-physical | Presence only. |
card_id | string | for card-control and card-get | Presence only. |
control | string | for card-control | Presence only. The refusal text names freeze, unfreeze and cancel; the CLI does not check the value against that list. |
Example
codespar issue --input '{
"action": "card-virtual",
"cardholder_id": "usr_123",
"program_id": "afg_123"
}'✓ issue card-virtual → active
ℹ Card id: crd_7bd2f1
ℹ Type: virtual
ℹ Last four: 4417
ℹ Cardholder: usr_123Stdout really is empty there. The human path prints the status line and then four conditional lines, one each for id, card_type, last_four and cardholder_id, and every one of them goes to stderr. Pass --json and the relationship inverts: stdout carries the result object and stderr is empty.
{
"id": "crd_7bd2f1",
"status": "active",
"card_type": "virtual",
"last_four": "4417",
"cardholder_id": "usr_123"
}That object is the stub's, shaped to exercise the five fields the CLI reads. The real result shape of codespar_issue was not verified here. The status in the first stderr line falls back to the literal ok when the result carries no status.
Refusals
| Trigger | What is printed on stderr | Exit |
|---|---|---|
| no API key resolvable | ✗ Not logged in. Run `codespar login` or set CODESPAR_API_KEY. | 1 |
neither --input nor --input-file | ✗ issue requires --input '<json>' or --input-file <path>. followed by an example | 1 |
| both of them | ✗ Pass either --input or --input-file, not both. | 1 |
| the payload is not valid JSON | ✗ --input is not valid JSON: <parser message> | 1 |
| the payload is an array or a scalar | ✗ --input must be a JSON object. | 1 |
an unknown action | ✗ issue.action must be one of: card-virtual, card-physical, card-control, card-get. | 1 |
card-physical with no address | ✗ issue.shipping_address is required when action=card-physical. | 1 |
card-control with no control | the message below, naming the three controls | 1 |
| the tool call fails, for any reason | ✗ internal error: and a stack trace reading Error: issue failed: <reason> | 2 |
Two of those messages are long enough that a table cell mangles them. Verbatim:
✗ issue requires --input '<json>' or --input-file <path>. Example: --input '{"action":"card-virtual","cardholder_id":"usr_123","program_id":"afg_123"}'
✗ issue.control (freeze | unfreeze | cancel) is required when action=card-control.The last row of the table covers a scope refusal, a provider rejection and a genuine bug alike. Measured twice: a stub answering {"success":false,"error":"cardholder not found in program afg_123"} and a stub answering 403 insufficient_scope both produced exit 2 with a stack, the only difference being the text after issue failed:.
codespar verification-status
Opens a throwaway session and reads where an asynchronous KYC inquiry landed for one tool call, once by default, or holding an event stream open until the terminal frame with --stream.
https://api.codespar.dev/v1/tool-calls/{id}/verification-statusThe streaming form reads /v1/tool-calls/{id}/verification-status/stream instead. Both are in the published document; see Tool calls for the response schema, and verificationStatus for the typed wrapper this command is built on.
Arguments and flags
| Name | Type | Required | What it does |
|---|---|---|---|
<tool-call-id> | string | yes | The tool call whose inquiry you are following. |
--stream | boolean | no | Holds the stream open and prints each update until the terminal frame. |
--timeout <ms> | integer | no | Bound on the stream. Defaults to 600000, a constant in the command rather than a Commander default, so it does not appear as (default: ...) in --help. |
-u, --user <id> | string | no | Session user. Defaults to the literal cli-user. |
--timeout must be a positive number and is checked before the session opens. It applies to the stream; the one-shot read does not use it.
Example
codespar verification-status tcl_kyc_9a1✓ verification_status: approved
ℹ tool_call_id: tcl_kyc_9a1
ℹ original_status: pending
ℹ idempotency_key: idem_5c2e
ℹ hosted_url: https://verify.codespar.dev/i/inq_44a2
ℹ events: 2As with issue, the human mode leaves stdout empty. idempotency_key falls back to - when absent; hosted_url is the one line printed only when the field is there, and apart from the label on the first line it is the only thing that separates this rendering from its settlement twin codespar payment-status, which prints the same four remaining lines and has no hosted_url field at all. events is the count, not the list: to see the events themselves, use --json, which puts the whole result object on stdout unchanged.
{
"tool_call_id": "tcl_kyc_9a1",
"verification_status": "approved",
"original_status": "pending",
"idempotency_key": "idem_5c2e",
"hosted_url": "https://verify.codespar.dev/i/inq_44a2",
"events": [
{ "at": "2026-09-12T18:02:11Z", "status": "pending" },
{ "at": "2026-09-12T18:04:39Z", "status": "approved" }
]
}Under --stream, each update prints as update #N: verification_status=<s> events=<n> on stderr. With --stream --json, each update is its own complete JSON document on stdout, followed by the final result — several documents in one stream, not one, so read them as a sequence rather than piping straight into a single-document parser. Ctrl+C aborts the stream and is reported as an abort, not as a result.
Refusals
| Trigger | What is printed on stderr | Exit |
|---|---|---|
| no API key resolvable | ✗ Not logged in. Run `codespar login` or set CODESPAR_API_KEY. | 1 |
| no tool-call id | error: missing required argument 'tool-call-id' | 1 |
--timeout not a positive number | ✗ --timeout must be a positive integer (milliseconds). | 1 |
| the read fails, for any reason | ✗ internal error: and a stack trace reading CodesparApiError: verificationStatus failed: <status> <body> | 2 |
The last row was measured against a stub answering 403 insufficient_scope. The same caution as issue applies: 2 here is a refused call, not a crash.