Intents and retries
Every governed action your agent takes is an intent. When your agent moves money, creates a counterparty, asks a counterparty for its payment details, or requests a temporary limit increase (the console calls these temporary grants), we return an intent that records the action and its outcome. Reads, such as balances, transactions, and deposit addresses, do not return an intent.
An intent can pass through more than one status before it reaches a final one, so we recommend that your agent read the status every time instead of assuming the outcome.
Statuses and resolutions
Use status for control flow. Below are the five statuses that an intent may
have, along with the matching resolution:
status |
resolution |
Meaning |
|---|---|---|
completed |
completed |
The action has succeeded. |
pending |
null |
The intent is waiting for a person to approve it. |
processing |
null |
Catena has accepted the intent, and it is in progress. |
blocked |
blocked, denied, or cancelled |
A policy has blocked it, a person has denied it, or it has been cancelled. This status is final. |
failed |
failed or expired |
The intent did not complete. This status is final. |
resolution is the final outcome, and it is null while the intent is open. A
policy block and a denial both have the status blocked, so you can use
resolution to tell them apart (a cancelled intent has
resolution: "cancelled"). We may add new resolution values, so pass through
any value you don't recognize.
Only completed means success. If an intent is pending or processing,
read it again until it reaches a final status:
catena intents get int_...Intent fields
In addition to status and resolution, an intent carries the following fields
that explain its result:
| Field | Contents |
|---|---|
reasons |
The policy decision or the approval route. Your agent should summarize it for the user. |
expiresAt |
The approval deadline for a pending intent. |
metadata.dataUrl |
A link to the approval or the result in the console. Give it to the person who approves. |
data |
The result, once one exists: the transaction for a send or transfer, or the new counterparty for a creation. It is null until then. |
To list your agent's recent intents, you can filter by status, type, and start
time (e.g., --status pending):
catena intents list --status pending --status processing
catena intents list --type send --start 2026-10-01T00:00:00ZExit codes
catena send and catena transfer exit 0 as soon as they submit the intent,
even when the intent is blocked, pending, or later fails. This means a
successful exit does not tell you whether money moved, so read .status and
.reasons every time.
Cancel an intent
An agent can cancel its own intent while the intent is waiting for approval, or after approval but before execution starts:
catena intents cancel int_...The intent will end with resolution: "cancelled", and cancelling twice is
safe. However, an intent that is already executing cannot be cancelled, and the
API will return 409 (intent_not_cancellable).
Retry safely
By default, each request waits up to 80 seconds. If a request times out, that means the client stopped waiting, but the server may still have acted on the request.
Reads are safe to run again. Intents are different, because an intent needs an idempotency key to be retried safely. The CLI and the SDK generate a new key for every call unless you pass one, so sending identical inputs twice will not mark the second request as a retry.
Example: retrying an ACH payment
Let's say Cora's Coffee's agent is paying Pete's Packaging $125.00 by ACH for
invoice 4471. To be able to retry, the agent chooses a key (here,
7c4f2a8e-invoice-4471) and saves it before the first attempt:
catena send --rail cpr_... --amount 125.00 --method ach \
--idempotency-key 7c4f2a8e-invoice-4471If the request times out, the agent runs the same command with the same key, and no money will move a second time. Below is how we handle a repeated key, and when to use a new one:
| Case | What happens |
|---|---|
| Same key, same inputs | We return the original intent, with replayed: true. No money moves a second time. |
| Same key, different inputs | We return 409. |
| The policy changed | A retry keeps the original source account, even if the policy changed. |
| A block was cleared | After an admin clears a block (by changing the policy or approving an override), send again with a new key. The blocked intent stays blocked and moved no money, so the new key cannot pay twice. |
| A new operation | Use a new key for a new operation, or when an intent failed and another attempt is correct. |
ImportantCompleted intents
Don't pay a
completedintent again to recover from an error.
Payment errors and retries
The following messages can come back from a payment, and each one calls for a different response:
| Message | What to do |
|---|---|
"Already prepared" (409 wallet_send_intent_not_executing) |
Run intents get <id> if you have the ID. Otherwise ask an admin. Do not retry with a new key. |
| "Created but never submitted; no money moved." | Fix the problem in the message, then use a new key. |
| "Could not be confirmed." | Read intents get <id> until it is final. Submit again only after blocked or failed. |
Using the SDK
In the SDK, submitIntent returns the intent's current state (it does not wait
for execution). You can poll with getIntent(id), cancel with
cancelIntent(id), and pass idempotencyKey yourself when a retry must refer
to the same operation.
const intent = await client.submitIntent({
action: { type: "send", counterpartyRailId, amount: "125.00", method: "ach" },
idempotencyKey,
})
if (intent.status === "pending") {
// Waiting for a person. intent.reasons says why.
}