Skip to main content
The agent’s default vocabulary is the platform’s own: recipients, mandates, destinations. If your product calls them payees, spending limits and payout methods, a customer reading the agent’s reply meets three words they have never seen — in the middle of authorizing money. 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.
No client id in the path. You are resolved from your API key, so a policy can only ever be your own — there is nothing to look up and nothing to point at by mistake. 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:
Both are the same two payout methods on the same platform recipient. What differs is how many rows your product draws — and therefore what “add a way to pay Acme” means to the person reading it. The agent is given the same shape, so it says the matching thing:
  • 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.
That warning is not a rule the model was given. Under flat both entries are in front of it, so it writes the sentence that tells them apart. Underneath, the tool result it reads:
Two things to notice. 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_mandate in 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: every create_scheduled_payments must come with a create_auto_account in the same proposal, so every payout leaves through the conversion account.
  • bank_only — a crypto payout is refused outright. create_crypto_destination is 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:
Both the container key and the id field take the noun, with spaces becoming underscores — 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.”
A limit is stored in the asset that funds it. Under a system-chosen funding route the customer never picked that asset and should never be shown it — but without a unit the agent can only quote the number bare or reach for the funding asset, and it reached.
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.
The charsets are a security boundary, not tidiness. A label becomes a JSON field name in a platform-built struct and a word in the model’s context, so an unbounded one is a channel for smuggling a sentence — an instruction, a fake tool result — into the drafting prompt through a field you control.

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:
Which produces, for “send Priya 5 USDC on base-sepolia at 0x2Bd9…”:
  • 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_payee naming 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 of client_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:
At 1000 bps that actuates as 1.333334 leaving the wallet, and the payee receives the 1.20 that was named. Rounded UP at 6dp, because the provider rounds its own fee up and rounding to nearest leaves the payee an atomic unit short. 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.

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.

One more refusal worth knowing about

An identical payment cannot be drafted twice. Same destination, same amount, same asset, same UTC day — whether the first one is still scheduled or has already gone out — is refused, and the agent is told why so it can correct itself in the same turn rather than showing your customer a second card. This exists because a conversation can revisit a request. The agent is stateless and re-reads the transcript each turn, so a follow-up like “go ahead” — sent after something unrelated was sorted out — can read as “draft all of it again”. Repeating a payment deliberately is still expressible — change the amount, or the date. What is refused is the one that is indistinguishable from an accident.

Testing a policy

Register one, run a conversation, register the next. Changes apply to the very next turn, so the loop is as tight as editing a config file.
A quick way to see the strictness is to register something wrong on purpose:
Registration is where a policy is validated, so a bad one is refused before any conversation happens rather than on a customer’s first turn.