Skip to main content

Mandate and governance

The four commands that mint spend authority, verify a presentation token offline, issue spend cards, and read KYC settlement.

8 min read
View MarkdownEdit on GitHub

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.

OptionTypeRequiredWhat it does
--api-key <key>stringnoAPI key for this invocation. Beats the environment and the config file.
--base-url <url>stringnoAPI host. Falls back to https://api.codespar.dev when nothing sets it.
--project <id>stringnoSends x-codespar-project on requests the CLI's own HTTP client makes.
--jsonbooleannoMachine-readable output on stdout instead of the human block.
-v, --versionbooleannoPrints 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.

ExitWhat is printedMeasured on
0the resultevery success, and any --help
1✗ <message> on stderr, no stackargument and credential refusals in all four commands
1error: <message> on stderr, no Commander's own refusals: a missing required argument or option
1the full report, then a note on stderrmandate verify when the token does not verify
2✗ internal error: and a stack traceissue 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.

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.

POSThttps://api.codespar.dev/v1/consents/init
Deprecated
POSThttps://api.codespar.dev/v1/consents/{token}/submit

The 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

FlagTypeRequiredWhat it does
-c, --consumer <id>stringyesConsumer id. Sent as consumer_id on the submit leg.
--agent <id>stringyesAgent the mandate authorizes. Sent as agent_id on the init leg.
--purpose <text>stringyesHuman purpose, signed into the mandate. Becomes intent.purpose.
-p, --payee <list>stringyesAllowlisted payees, comma-separated. Each entry is trimmed and empties dropped; the result becomes intent.merchant_allowlist.
--cap <minor>integerwhen no --slotTotal cap in minor units. Becomes intent.cap_minor.
--per-tx-cap <minor>integerwhen no --slotPer-transaction cap in minor units. Becomes intent.per_tx_cap_minor.
--slot <spec>CURRENCY:RAIL:CAP:PER_TX_CAPnoRepeatable. Builds intent.slots, one entry per currency. Default is an empty list.
--currency <code>stringnoDefault USDC. The single-currency path only.
--rail <rail>stringnoDefault usdc-onchain. Sent on the submit body.
--ttl <seconds>integernoDefault 86400. Becomes intent.mandate_ttl_seconds.
--pin-kind <kind>stringnoDefault merchant-id. Becomes intent.merchant_pin_kind.
--provider-token <token>stringnoSent 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:

exit 1
✗ --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

Two slots, one signature
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:25
Output
stdout
mandate    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)
stderr
✓ 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_7fa21c

The 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.

--json, stdout
{
  "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.

TriggerWhat is printed on stderrExit
no API key resolvable✗ Not logged in. Run `codespar login` or set CODESPAR_API_KEY.1
a missing required flagerror: required option '--agent <id>' not specified1
--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:5001
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

NameTypeRequiredWhat it does
<token>stringyesThe presentation token, base64url.
--agent-pubkey <hex>stringnoRaw 32-byte Ed25519 agent key, hex. Its presence forces offline mode.
--issuer-pubkey <hex>stringnoRaw 32-byte Ed25519 issuer key, hex. Its presence forces offline mode.
--issuer-did <did>stringnoIssuer 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

Offline, both keys
codespar mandate verify "$TOKEN" \
  --agent-pubkey 2abe98d728d767007bb7329e9d1f1d7f107b2f1288457291f300ece461349b87 \
  --issuer-pubkey 272b1badc6c62899dc5a1a1c25f09b06ab57db69e31e938ac30791c7bee9f17a
Output
stdout
signatures
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
stderr
✓ 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:

--json, stdout
{
  "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

TriggerWhat is printed on stderrExit
no tokenerror: 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 checkedthe report, then ⚠ mandate token NOT verified1

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.

POSThttps://api.codespar.dev/v1/sessions/{id}/execute
Can move money

Resolves `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

FlagTypeRequiredWhat it does
-i, --input <json>stringone of the twoThe meta-tool arguments as a JSON object.
-f, --input-file <path>stringone of the twoThe same object read from a file.
-u, --user <id>stringnoSession 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.

FieldTypeRequiredWhat the CLI enforces
actionstringalwaysOne of card-virtual, card-physical, card-control, card-get.
cardholder_idstringfor card-virtual and card-physicalPresence only.
program_idstringfor card-virtual and card-physicalPresence only.
shipping_addressobjectfor card-physicalPresence only.
card_idstringfor card-control and card-getPresence only.
controlstringfor card-controlPresence only. The refusal text names freeze, unfreeze and cancel; the CLI does not check the value against that list.

Example

A virtual card
codespar issue --input '{
  "action": "card-virtual",
  "cardholder_id": "usr_123",
  "program_id": "afg_123"
}'
Output (stdout is empty)
stderr
✓ issue card-virtual → active
ℹ Card id: crd_7bd2f1
ℹ Type: virtual
ℹ Last four: 4417
ℹ Cardholder: usr_123

Stdout 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.

--json, stdout
{
  "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

TriggerWhat is printed on stderrExit
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 example1
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 controlthe message below, naming the three controls1
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:

stderr
✗ 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.

GEThttps://api.codespar.dev/v1/tool-calls/{id}/verification-status

The 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

NameTypeRequiredWhat it does
<tool-call-id>stringyesThe tool call whose inquiry you are following.
--streambooleannoHolds the stream open and prints each update until the terminal frame.
--timeout <ms>integernoBound 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>stringnoSession 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

One read
codespar verification-status tcl_kyc_9a1
Output (stdout is empty)
stderr
✓ 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: 2

As 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.

--json, stdout
{
  "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

TriggerWhat is printed on stderrExit
no API key resolvable✗ Not logged in. Run `codespar login` or set CODESPAR_API_KEY.1
no tool-call iderror: 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.

Mandate and governance | CodeSpar