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.
Beta, 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_defaults 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.
developer_fee_bps, or a
fixed amount per payment, developer_fee_fixed. Sending both keys for one
payout type is refused with a 400, even when one of them is 0. A fixed fee
is a decimal string such as "10.00", with at most 2 decimal places, and must
be greater than 0: to charge nothing, set developer_fee_bps to 0 instead.
It has no currency of its own: it is charged in units of the stablecoin the
payment sends, so "10.00" on a USDC payment is 10.00 USDC.
developer_fee_bps to
0, and that payout type has no default fee. Nothing is charged and nothing is
added to the amount unless the action sets its own developer_fee_bps or
developer_fee_fixed, and the agent describes that payout type as free.
Renamed from
developer_fee. developer_fee.swap_bps is now
developer_fee_defaults.swap.developer_fee_bps, developer_fee.offramp_bps is
now developer_fee_defaults.offramp.developer_fee_bps, and the action’s
fee_bps is now developer_fee_bps. The old names are no longer accepted: a
request that uses one gets a 400 whose detail names the replacement, for
example developer_fee was renamed to developer_fee_defaults; swap_bps is now developer_fee_defaults.swap.developer_fee_bps. or actions[0].fee_bps was renamed to developer_fee_bps. A request-level developer_fee_bps, shown in
earlier versions of this page, is no longer read: send developer_fee_defaults.amount is what the payee RECEIVES. The server adds the fee on
top, and the payee receives exactly the amount named. How much more leaves the
funding wallet depends on the kind of fee.
With a percentage fee, slightly more leaves the funding wallet:
amount + developer_fee_fixed, exactly, with no rounding. A fixed fee is the
same on every payment whatever its size, so on a small payment it can be a
large share of what leaves:
"10.00" that actuates as 110.00 USDC leaving the
funding wallet, and the payee receives the 100.00 that was named. Under a fixed
fee the amount must be at least 0.01, because the provider does not deliver less
than that after the fee; a smaller amount is refused.
Spending limits apply to the grossed amount, under either kind of fee: what
leaves the funding wallet, fee included. The 100.00 payment above is checked as
110.00 against max_per_tx, and counts 110.00 toward every amount window cap,
aggregate and per-target. A mandate that allows exactly 100.00 per payment does
not cover it, and neither does a max_per_tx of 1.20 cover the 1.20 payment at
1000 bps. Some shortfalls are caught while the agent drafts the payment in
conversation. When the proposal drafts the mandate itself, its max_per_tx is
checked against each grossed payment, and its max_amount_in_window against the
grossed payments that fall in one window (all of them, for a window of NONE);
the agent is told to raise either one. A series that starts when the customer
approves it, with no dates and no start_at, could land in any window, so it is
checked at the most of its payments one window can hold, whatever the approval
date, on top of the fullest window of the proposal’s other payments. A max_amount_per_target_in_window is not checked until
fire time. Under mandate_strategy: external_only, a payment the customer’s
existing limit doesn’t cover (over its per-payment cap, or no room left in its
window) is refused while drafting, and the agent asks the customer to change
their limit in your app. Otherwise a payment an existing mandate doesn’t cover
fails at fire time with mandate_denied, as does any shortfall in a series you
send straight to POST /instructions. Every cap is still enforced at fire
time. See
Mandate rule bounds.
Set 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. Under a fixed fee the amount must be at least the fee plus 0.01,
so that something reaches the payee: with a fee of "10.00", an amount of
"110.00" delivers 100.00, "10.01" delivers 0.01, and "10.00" is refused.
On POST /instructions that is a 400 whose detail names the action and ends
amount 10.00 does not cover the fixed developer fee of 10.00. Send at least 10.01 or lower the fee. While drafting, the agent is told the same and asks the
customer to send more, or to have the fee added on top instead.
Where the fee applies
Only where something actually converts. The server derives this; you do not declare it:
Charging for bank payouts is your pricing decision. Leave
offramp out and
bank payouts carry no developer fee. To set a fee for one account only, put
developer_fee_bps or developer_fee_fixed on its create_auto_account
action, never both. The gross-up follows whichever fee applies:
POST /instructions refuses the action with a 400, whether
the fixed fee came from developer_fee_defaults or from the action itself. Its
detail names the action and ends developer_fee_fixed is not supported for this account; use developer_fee_bps.
The provider is chosen on accept, so drafting cannot catch this and a draft the
customer approved can still be refused here. Charge developer_fee_bps for
that payout type instead. The same restriction applies to accounts created
through the REST API; see
Where fixed fees are available.
The agent is told the fee, 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 fee. The number that bills is
the one on your request.

