Skip to main content

Paying from an AI agent

Give an autonomous agent the ability to spend without giving it the ability to move money unilaterally. The agent proposes a transfer; something else confirms it. Neither half can do the other's job, and the confirmer cannot be tricked into approving different terms than the ones it was shown.

This is the same treasury approval machinery, described for the case it turns out to fit best. Nothing here is agent-specific on the ledger — which is the point. The guarantees come from key authority enforced by the contract, not from an agent framework behaving itself.

See it live

The playground runs this end to end against the public testnet. "Run the agents" arms a treasury key and a proposer key, then has one agent propose payments while the treasury policy approves or holds each — every authority grant and proposal is a real ledger transaction with a scanner link. The run funds itself from the testnet faucet, and an approved payment settles on the ledger as it is confirmed: no chain transaction and no gas. It shows in the identity's Ledger transfers on the scanner.

Why not a REST-scoped key​

The obvious design is a credential with a spend limit recorded next to it, checked when the agent calls the API. It does not hold up.

A server-side check only constrains calls that go through that server. The ledger accepts a correctly signed transaction from any key with authority, whoever relayed it. An agent holding a key that can sign a send can send — a limit recorded beside that key on a server is a policy note, not a constraint. Off-ledger scopes are exactly that kind of check, which is why they are not what this page uses.

Key authority is different: Varnir.CryptoTransfer reads the signing key's stake at consensus time and refuses to execute. There is no code path — no misconfiguration, no compromised gateway, no prompt injection — that turns a stake-0 key into one that can move funds.

The shape​

Three keys, three jobs:

roleStakeHeld byCan
owner key100you, offlineeverything, including handing out the roles below
proposer0the agentpropose transfers, read balances
verifier80your approval serviceconfirm — and nothing else
dual80a peer agentpropose and confirm — never its own

Two shapes fall out of this, and which you want depends on whether your agents are peers.

Asymmetric — proposer + verifier. The agent can only ask; the approval service can only answer. Neither can do the other's job at all. This is the safer default when one side is untrusted software and the other is your own policy engine.

Peer — two dual keys. Either agent can raise a transfer and either can confirm the other's, with nobody privileged above them. The one thing neither can do is confirm the transfer it raised itself.

That last rule is enforced by key, not by role: the proposal records which key created it, and a confirmation signed by that key is rejected with Cannot verify your own proposal. So it holds however you arrange things.

The owner key is not the agent's and is not the approver's. It is the one that provisions the other two, and it should not be in either process. It can confirm a proposal as well, on anything it did not itself raise — the fallback for an approver that is down, not part of the normal loop.

Provisioning​

Done once, with the owner key:

import { VarnirClient } from '@varnir/chain-client';

const owner = await VarnirClient.onboard({ network: 'testnet', privateKeyHex });

// Keep both private halves. Nothing can hand them back later.
const agentKey = VarnirClient.generateKey();
const approverKey = VarnirClient.generateKey();

// Adding the treasury key is what arms approval for the identity: it sets the
// confirming key and the threshold together.
await owner.addKey({
publicKeyHex: approverKey.publicKeyHex,
role: 'dual',
approvalThreshold: '0', // '0' = every send needs approval
});

await owner.addKey({ publicKeyHex: agentKey.publicKeyHex, role: 'proposer' });

Give the agent agentKey.privateKeyHex and the identity id. That is the whole credential — there is nothing else it needs, and nothing else it can use.

Order matters

Add the treasury key first. A spender key on an identity with no treasury key is not diverted to approval — its sends are simply rejected. Arm the control before you hand out the key it constrains.

Thresholds​

approvalThreshold decides when approval kicks in:

ValueEffect
'0'every send needs approval
'-1'none do — the key keeps its authority but the control is off
'25000000'sends above this raw smallest-unit amount need approval
{'TRX-NILE.#native': '0'}per network.token, for different limits per asset — token is #native or the L1 contract address, e.g. SEP.4ea636f3…

Change it later without touching the keys:

await owner.setApprovalThreshold('25000000');

Letting an agent spend small amounts freely and escalating only above a limit is usually the design you want: approval you must give for every trivial payment is approval that gets automated away without being read.

The agent proposes​

The agent's code is an ordinary send. It does not need to know it is constrained:

const agent = new VarnirClient({
network: 'testnet',
identity, // the same identity, not its own
privateKeyHex: agentKey.privateKeyHex,
});

const result = await agent.send({ chain: 'niles', to: recipient, amount: '250' });

if (result.proposal) {
console.log('Awaiting approval:', result.proposal.id); // nothing moved
} else {
console.log('Sent:', result.umid);
}

result.proposal is the thing to branch on. The call succeeded and umid is a real transaction id in both cases — the transaction just succeeded at recording a proposal rather than at sending. An agent that reports "paid" because it got a umid will be wrong for exactly the payments that matter most, and will be wrong confidently, which is worse.

A third outcome an agent must handle: OUTCOME_UNKNOWN. send can throw after the transaction has already committed, if the stream it created (the proposal, for a co-signed amount) can't be read back in time. The agent then genuinely doesn't know whether the payment went straight through, is sitting in the approver's pending list, or neither yet. Never let the agent retry the send — that risks paying twice for a payment that already committed. The self-hosted agent server enforces this itself: a tool call that hits OUTCOME_UNKNOWN comes back as HTTP 409, with the message telling the assistant to confirm before retrying, carrying the committed umid so it can be checked against the identity's transaction history or a pending approval.

The approver confirms​

The approver polls the identity — the same document it already reads for balances:

const approver = new VarnirClient({
network: 'testnet',
identity,
privateKeyHex: approverKey.privateKeyHex,
});

const stop = approver.watchPendingApprovals(
async ({ id, amount, to, isExpired }) => {
if (isExpired) return; // nothing can confirm it now

if (!(await allowed(amount, to))) {
await approver.voidTransfer(id); // say no, rather than say nothing
return;
}

await approver.confirmTransfer(id);
},
{ intervalMs: 15_000, onError: (e) => console.error('poll failed', e) },
);

confirmTransfer executes on the terms the ledger stored. Amount, destination, network and token are copied out of the proposal over whatever the confirming transaction claims, so a compromised approver cannot redirect a payment or change its size — the worst it can do is approve something it should have refused, or refuse everything. Proposals are single-use, so a retry after an ambiguous timeout cannot pay twice.

Refusing, and the deadline​

An approver that decides against a payment has two ways to act on that, and they are not the same thing.

voidTransfer(id) rejects it: the proposal is stamped terminal, its entry leaves the identity's pending list, and a later confirmation of it is refused outright. Nothing moves — the contract's void branch never reaches any amount. Either the approver or the agent that raised it can void; the agent withdrawing its own request is the one thing it can do to a proposal besides raise it, and it still cannot confirm one.

Doing nothing is the other way, and it is now bounded: a proposal is confirmable for 24 hours and no longer, because confirming skips the spending-policy check — see Treasury approval — Expiry for why that makes the deadline matter, not just housekeeping. Past that the contract refuses the confirmation, watchPendingApprovals reports the entry with isExpired: true, and confirmTransfer throws EXPIRED before it submits anything.

Prefer voiding over letting a proposal lapse: it clears the pending list and is terminal immediately rather than in a day. Treasury approval has the full rule for who may void and what the SDK throws when they may not.

What this actually protects against​

Worth being precise, because it is easy to over-claim:

  • A compromised or misled agent cannot move money. Prompt injection, a poisoned tool result, a bug in its planner — none of it matters. The ledger will not execute its sends.
  • A compromised approver cannot invent a payment. It can only confirm proposals that exist, on their stored terms — and cannot wave through one it raised itself, so compromising a single key is not enough even when that key can do both jobs. What it can do is refuse everything, by voiding proposals as they appear. That is denial of service, not theft, and it is the shape of the worst case here.
  • Together they can. This is a separation of duties, not a defence against one attacker holding both keys. Run the approver somewhere the agent cannot reach, or the split buys you nothing.
  • It does not bound the amount. An agent can propose anything; the threshold decides what needs approving, not what may be asked for. Amount policy belongs in your approver's own logic.

Current limits​

  • One confirmation is enough. Any key with treasury authority other than the proposer executes it — the owner key included. M-of-N — "two of three must agree" — is not supported yet.
  • One process can still hold two keys. Self-confirmation is refused by key, so it would need two different stake-80 keys in the same process to defeat the split. The ledger cannot see where a key is running, so keeping them apart remains a deployment decision — but the split is no longer defeated by a single key approving itself.
  • The 24-hour lifetime is fixed. It is the same for every proposal and every identity, and nothing sweeps the expired ones: an expired proposal cannot be confirmed but stays on the pending list until somebody voids it.
  • Polling, not push. The contract emits treasury-pending, but nothing forwards it to subscribers yet. watchPendingApprovals polls, and is written so a real transport can back it later without callers changing anything.

Managing the keys themselves​

The SDK now issues and reads back the keys this flow rests on, so you no longer have to keep your own record of what an identity has handed out. All three are signed with the identity's own stake-100 key. See Keys for the full model — every role on the spectrum, type, and how scopes and policy fit together, not just the proposer/verifier/dual roles this page uses:

  • addKey({publicKeyHex, role, scopes, policy, approvalThreshold}) attaches a key and gives it a role (proposer, verifier, dual, readonly, offledger), optionally with off-ledger REST scopes and an on-ledger spend policy. Returns the transaction id.
  • listLedgerKeys() returns every authority on the identity, read straight off the ledger: publicKey, type, stake, label, the umid that attached it, a derived role, isOwner for the stake-100 onboarding key (which is returned, not filtered out), and the scopes/policy stored against that key. Read its doc comment on read consistency before trusting a result taken immediately after a mutation.
  • removeKey(publicKeyHex) detaches a key and its restrictions. The identity's own owner key cannot be removed, and the SDK refuses it rather than submitting a transaction that would change nothing.