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.
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:
role | Stake | Held by | Can |
|---|---|---|---|
| owner key | 100 | you, offline | everything, including handing out the roles below |
proposer | 0 | the agent | propose transfers, read balances |
verifier | 80 | your approval service | confirm — and nothing else |
dual | 80 | a peer agent | propose 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.
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:
| Value | Effect |
|---|---|
'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.watchPendingApprovalspolls, 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, theumidthat attached it, a derivedrole,isOwnerfor the stake-100 onboarding key (which is returned, not filtered out), and thescopes/policystored 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.