client_policy fixes that, and does more than rename things. It changes what
the drafting model sees and constrains what it may propose, so the agent
narrates in your nouns and refuses what your product refuses.
Alpha, and entirely opt-in. Register nothing and the agent behaves exactly as
it always has — platform’s own vocabulary, no added constraints. Registering an
empty policy is the same thing, so adopting it is reversible: nothing you can set
here is a one-way door.
The shape
Strict by design. An unknown key, an unknown value, or a label for a concept
the server does not implement is a
400. “Accepted” always means “enforced” —
there is no silently-ignored field.
Registering it
Registration is the only way to set a policy. It describes the client, so it is not something a request carries — a conversation is two calls, and both are judged by the same registered policy.GET /agentic-policy returns what is currently registered.
It then applies to every drafting turn and every accept, with nothing to
remember per request.
A full replace, not a merge. Send the whole policy each time; whatever you
omit is cleared. An identical re-registration is a no-op, so updated_at means
“when this policy last changed” rather than “when a service last restarted”.
Changes take effect on the next turn. There is no cache — which is also how
you test a policy: register, run a conversation, register something else. To go
back to platform defaults, register an empty policy.
Every field, and what changes
Each field is independent. Omit one and that dimension behaves exactly as it does with no policy at all — the examples below show both sides.payee_model — how many things a payee with two payout methods is
One person with a bank account and a wallet is one payee in the platform’s
model and two in a product that treats a payout method as the payee. Pick the
one your own screens already use, because your customer is looking at those
screens while they read the agent’s reply.
Say Acme is saved with a bank account, and your customer now wants to pay them in
USDC on Base. What their recipients list looks like afterwards:
nested— “I’ll add a USDC payout method to Acme.” One recipient, now with a second way to be paid.flat— “I’ll add a new payee, Acme, paid in USDC on Base — note you already have an Acme paid by bank account ••••4321.” A second row appears, so it is announced as a new entry and it warns about the near-duplicate, which is the thing a flat list makes easy to get wrong.
flat both entries are in
front of it, so it writes the sentence that tells them apart.
Underneath, the tool result it reads:
paid_by is a phrase the platform renders from the payout
method’s own fields, so the flat view carries no network_id or address for
the model to reason about — but rails survives, because a bank payout has to
name a rail the account really supports and that cannot be inferred from prose.
And payee_ref is the destination id, entity_ref is the recipient id —
opaque strings the model copies into actions, not a second id space to translate.
The disambiguation in the second is not a rule the model was given. Both entries
are in front of it, so it writes the sentence that tells them apart.
mandate_strategy — whether the agent may create authority
“Pay Priya 5 USDC”, where Priya is not on the customer’s limit:
- unset — the agent proposes the payment and a new mandate to authorize it, and the customer signs both together.
external_only— the agent may not draft one. It proposes the payee alone and returns a blocker; your app collects the signature in its own limit editor.create_mandatein a proposal is refused at accept:
payout_route — how money is allowed to leave
“Pay Priya 5 USDC on Base”, funded from a stablecoin balance:
- unset — a direct payment when a funding wallet is already on Base; a conversion only when the chains differ.
conversion_account_only— always paired: everycreate_scheduled_paymentsmust come with acreate_auto_accountin the same proposal, so every payout leaves through the conversion account.bank_only— a crypto payout is refused outright.create_crypto_destinationis not a legal action, and a conversion must name a bank rail.
payout_assets — what a payee may receive
“Pay Joe 0.45 RD”, where RD is what the customer holds:
- unset — the agent drafts an RD payout. RD settles on Base, so nothing refuses it.
["USDC","USDT"]— it does not, and says why:
A recipient can only receive USDC or USDT — RD isn’t a supported output asset. Which would you like Joe to receive?This constrains the payout leg only. The deposit leg — what funds a conversion — is untouched, and is usually a different asset. That distinction is the point: a customer naming RD has named what they hold, not what Joe gets.
labels — your nouns, in the data the model reads
Three concepts can be renamed. Anything else is a 400.
Labels rewrite the tool results, so the model never learns platform’s word in
the first place. With
{"limit": "spending limit"}, active_mandates returns:
spending limit gives you spending_limit and
spending_limit_id. Every budget field keeps its own name and every number is
untouched: this is a rename, not a reshape, because the arithmetic here has to
stay identical to the one the gate applies at fire time.
amounts_in appears only when you set limit_unit, and it is why the reply
changes:
- no labels — “Your mandate covers 1000 RD per transaction.”
- labelled — “Your spending limit covers 1000 USD per transaction.”
rule.asset and rule.network_id are still visible above because labels alone
do not hide them. Set payout_route: conversion_account_only and they move out
of rule entirely — with an internal funding leg they are plumbing the customer
did not choose, and leaving them in front of the model made it refuse a payment
that fit the limit exactly, on the grounds that the asset did not match.Blockers
A turn that cannot draft has always ended the same way: no proposals, and a sentence of prose. That is fine for a human and useless for your application — “extend the limit to cover Priya”, “I need her bank details” and “that rail is not supported” all arrive as an empty list and some text. A blocker is that refusal in a form software can branch on.
Two properties worth relying on:
Blockers may accompany proposals, and routinely do. The common case is a
payee who does not exist yet: the turn proposes creating them and reports that
the limit will not reach them, because your app has to do both, in that order —
accept the proposal so the payee has an id, then amend the limit to include it.
Treat the two as independent, never as alternatives.
payee_name always names a payee the proposal creates. It is your join key
back to the draft — a blocker carries no recipient id, because in the case this
blocker is about the payee does not exist yet. The name is checked against the
create_recipient actions in the same turn and snapped to their exact spelling,
so “Rita” in the proposal never arrives as “Rita Fernandez” in the blocker. If it
matches none of them, the blocker is dropped rather than handed to you
unjoinable.
Every blocker is verified server-side. The model reports one; the platform
re-checks it against its own data and drops it if the facts do not hold — a limit
that targets any (which covers every payee), an expired limit, a payee already
on the list. A code you receive never reflects only the model’s opinion.
Switch on
code and ignore codes you do not know — new ones are added over
time, and an unrecognized code should leave your existing behaviour unchanged.The full loop
The amendment adds a target and changes nothing else, so it can never raise a limit — which is what makes it reasonable to offer inside a conversation. See mandate signing for the bytes your customer signs.A worked example
A consumer banking app — payees are “recipients”, the limit is a “spending limit” edited in its own settings screen, and every payout is converted from the customer’s stablecoin balance — registers:- the agent calls a payee a recipient and a mandate a spending limit, everywhere
- it never offers to create or raise a limit — it points at the app instead
- it proposes the payee alone, so the app has an id to add
- it returns
mandate_does_not_cover_payeenaming the limit and the payee - amounts are quoted in USD, not in the stablecoin that funds them
The conversion fee, and which side of it your amount is on
Not part ofclient_policy — you send it per request — but it changes what the
numbers in a proposal mean, so it belongs next to them.
Send your fee on both calls: developer_fee_bps on
POST /payment-agents/{id}/proposals so the agent can tell the customer what it
costs, and on POST /instructions so it is applied. The drafting one only lets
the agent explain the charge; the accept one is what bills it.
By default amount is what the payee RECEIVES. The server adds the fee on
top, so slightly more leaves the funding wallet:
amount_includes_fee: true when the customer means the total instead —
“send 10 in all”, “including the fee”. Then amount is what leaves and the payee
receives less.
Where the fee applies
Only where something actually converts. The server derives this; you do not declare it:
A bank payout carries no conversion fee because the fee is a conversion fee;
charging it would make the same payment cost more through an agent than by hand.
If you do charge for payouts, say so with the action’s own
fee_bps — it
overrides the rail rule, and the gross-up follows it.
The agent is told the rate, never the arithmetic. It can quote your fee to a
customer and explain the two readings; it cannot compute a grossed figure, write
one into an action, or be talked into a different rate. The number that bills is
the one on your request.

