From REST to YEA
The short version: in REST, the agent works out how to do a job, one call at a time. In YEA, the agent says what it wants, and your service answers with ready-made plans: what will happen, what it costs, and whether it can be undone. This page shows how to turn a REST API into that, using a Stripe-backed billing API as the example.
One job, both ways
A customer writes to support: "Please refund the rest of this month, I'm cancelling."
REST: the agent makes four calls and does the math
GET /v1/customers/search?query=name:"Chen"
GET /v1/subscriptions?customer=cus_chen
GET /v1/charges?customer=cus_chen&limit=5
POST /v1/refunds
charge=ch_2 amount=2287Nothing tells the agent that a refund is permanent, or that the customer gets an email.
YEA: the agent makes one call and picks a plan
INTENT billing.refund {who: "Chen"}
2 proposals — risk: medium · undo: never
[p_tJnW1A-y] Refund 49.00 USD of ch_2 to Chen Wei (full)
~ update charge/ch_2.amount_refunded: 0.00 USD → 49.00 USD
> send chen@wei.studio — refund receipt; back on the card in 5–10 days
cost: 49.00 USD
[p_VPlXarXR] Refund 22.87 USD of ch_2 to Chen Wei (unused 14 days)
…
cost: 22.87 USDThe agent commits the second plan. Because it can't be undone, the human's policy decides whether the agent may do that alone.
Why not just wrap the REST API?
You can: yea openapi gives any REST API YEA's safety in one command. But the agent still makes every call itself, and each call is another model turn, which is where most of an agent's cost goes (live eval). A native service takes those turns away.
The cheat sheet
| In your REST API | In YEA |
|---|---|
Several GETs that answer one question | One ASK |
A POST, PATCH or DELETE | An INTENT that returns plans, then COMMIT |
| The write call itself | The plan's apply() |
| The call that reverses it, if any | The plan's revert(), which makes the plan undoable |
| Side effects your docs mention ("sends a receipt") | The plan's effects, shown to the agent and the human |
| Two endpoints for two ways of doing one job | Two plans from one intent |
Ids like cus_NffrFeUfNV2Hib | Names or emails, resolved by your service |
4xx errors | Errors that say how to fix the request |
Five steps
1. Start from what users say, not from your endpoints
Write down the requests people make, then find the endpoints each one needs. Each request becomes one capability.
| What the user says | Stripe endpoints today | YEA |
|---|---|---|
| "What's going on with Chen's account?" | GET customers, subscriptions, charges | ASK billing.customer {who} |
| "Refund Chen's last payment" | the three GETs, then POST /v1/refunds | INTENT billing.refund {who} |
| "Cancel Chen at the end of the month" | POST /v1/subscriptions/:id cancel_at_period_end=true | INTENT billing.cancel {who}, plan 1 |
| "Cancel Chen right now" | DELETE /v1/subscriptions/:id | INTENT billing.cancel {who}, plan 2 |
| "Actually, keep Chen's subscription" | POST /v1/subscriptions/:id cancel_at_period_end=false | UNDO the cancel. Nothing to build |
Five requests, eight endpoints, three capabilities. Endpoints agents shouldn't touch, like API keys or webhooks, are simply left out.
2. Turn reads into ASKs
One ASK makes the REST calls it needs and returns only what an agent needs to answer the question.
REST: three responses, abridged
{ "data": [{
"id": "cus_chen", "object": "customer",
"email": "chen@wei.studio", "name": "Chen Wei",
"created": 1680893993, "invoice_settings": { … }, …
}]}
{ "data": [{
"id": "sub_chen",
"items": { "data": [{
"current_period_end": 1791504000,
"price": { "id": "price_1Mo…", … }, …
}]}
{ "data": [{
"id": "ch_2", "amount": 4900, "amount_refunded": 0,
"billing_details": { … }, "outcome": { … }, …YEA: what the agent reads
id: cus_chen
name: Chen Wei
email: chen@wei.studio
plan: pro
renews: 2026-10-09
payments[2]{id,date,amount,refunded,status}:
ch_2,2026-09-09,49.00 USD,0.00 USD,succeeded
ch_1,2026-08-10,49.00 USD,0.00 USD,succeeded- Accept names and emails, and resolve them in the service.
- Return readable values: dates, not timestamps;
49.00 USD, not4900. - Keep rows flat, so they render as a table.
- Don't paginate. YEA trims long lists to the agent's budget for you.
The code for this ASK
async function customerOverview(stripe: Stripe, who: string) {
const c = await findOne(stripe, who);
const [sub, chs] = await Promise.all([
subscription(stripe, c),
charges(stripe, c),
]);
const price = sub?.items.data[0].price;
return {
id: c.id,
name: c.name,
email: c.email,
plan: price ? (price.nickname ?? price.id) : 'none',
...renewal(sub),
// Flat rows with only what an agent needs, so Lens renders a table.
payments: chs.map((ch) => ({
id: ch.id,
date: day(ch.created),
amount: amt(ch.amount, ch.currency),
refunded: amt(ch.amount_refunded, ch.currency),
status: ch.status,
})),
};
}3. Turn writes into INTENTs
An intent returns one or more plans. You fill in a plan from what you already know about the REST call:
| Plan field | What goes in it | For a refund |
|---|---|---|
summary | One line a person would read | Refund 22.87 USD of ch_2 to Chen Wei |
effects | Everything the call changes, including emails | the charge, and the receipt email |
cost | The money it moves | 22.87 USD |
apply() | The REST call | POST /v1/refunds |
revert() | The call that reverses it, if one exists | none, so undo: never |
function refunder(stripe: Stripe, c: Customer, ch: Charge) {
return (amount: number, why: string): Plan => ({
summary:
`Refund ${amt(amount, ch.currency)} of ${ch.id} to ${c.name}` +
` (${why})`,
effects: [
update(
`charge/${ch.id}`,
'amount_refunded',
amt(ch.amount_refunded, ch.currency),
amt(ch.amount_refunded + amount, ch.currency),
),
send(c.email, `refund receipt; back on the card in 5–10 days`),
],
cost: money(amount, ch.currency.toUpperCase()),
// YEA runs apply() at most once; the key covers a network retry.
apply: () =>
stripe(
'POST',
'/refunds',
{
charge: ch.id,
amount: String(amount),
reason: 'requested_by_customer',
},
`yea-refund-${ch.id}-${ch.amount_refunded}-${amount}`,
),
// No revert: Stripe can't reverse a refund, so the plan says
// "undo: never" and YEA never commits it automatically.
});
}When a reverse call exists, revert() makes it, and the plan becomes undoable. Cancelling at the end of the month is undone by setting cancel_at_period_end back to false:
const atPeriodEnd: Plan = {
summary: `Cancel ${c.name} on ${day(end)}; access until then`,
effects: [
update(`subscription/${sub.id}`, 'cancel_at_period_end', false, true),
],
// Whole days, so it reads "undo: 13d" and ends before the period does.
undoWindow: roundDown(end - Date.now() / 1000),
apply: () => stripe('POST', path, { cancel_at_period_end: 'true' }),
// The inverse REST call.
revert: () => stripe('POST', path, { cancel_at_period_end: 'false' }),
};- List every effect. The human approves what the plan says, so
apply()must do nothing more. - Add
revert()only if it really restores the old state. YEA only commits undoable plans automatically. - Offer the choices a person would, like "full or unused days" and "now or at period end". Put the safest common choice first.
4. Make errors say how to fix the request
| When | YEA error | Include |
|---|---|---|
| Params are out of range | invalid_params | A fix: refund the rest (49.00 USD) → {"amount":49} |
| A name matches several records | CLARIFY | One option per match |
404 | not_found | Which ASK would find it |
429 or 5xx | limit or unavailable | When to retry |
5. Skip the plumbing
Idempotency, pagination, confirmation screens and permission checks are part of the protocol. You don't build them. See Build a service for the library API.
The full example
examples/stripe-billing.ts puts these three capabilities in front of the real Stripe API. Try it with a test-mode key:
export STRIPE_SECRET_KEY=sk_test_…
export YEA_TRUST="$(yea whoami | awk '/principal/{print $2}')"
node examples/stripe-billing.ts
yea add yea://127.0.0.1:7453 # now your AI tool can use itThe playground runs the same design with made-up data, no key needed.
Show the full file
/**
* A YEA service in front of the real Stripe API: the full example in the
* "From REST to YEA" guide (site/guide/service-design.md).
*
* The agent sees three capabilities, not Stripe's endpoints. Each apply()
* makes the Stripe call, and each revert() makes the call that reverses it.
*
* STRIPE_SECRET_KEY=sk_test_… YEA_TRUST=ed25519:… \
* node examples/stripe-billing.ts
*/
import {
clarify,
fix,
money,
type Plan,
send,
service,
update,
YeaError,
} from '@yea-protocol/sdk';
const API = 'https://api.stripe.com/v1';
const amt = (minor: number, cur: string) =>
`${(minor / 100).toFixed(2)} ${cur.toUpperCase()}`;
const day = (unix: number) => new Date(unix * 1000).toISOString().slice(0, 10);
const roundDown = (secs: number) =>
secs >= 86400
? Math.floor(secs / 86400) * 86400
: Math.max(0, Math.floor(secs / 3600) * 3600);
/** The fields of Stripe's objects that this service reads. */
interface Customer {
id: string;
name: string;
email: string;
}
interface Charge {
id: string;
created: number;
amount: number;
amount_refunded: number;
currency: string;
status: string;
}
interface Subscription {
id: string;
status: string;
cancel_at_period_end: boolean;
items: {
data: {
current_period_start: number;
current_period_end: number;
price: { id: string; nickname: string | null };
}[];
};
}
interface List<T> {
data: T[];
}
export function stripeBilling(opts: {
key: string;
trust: string[];
fetch?: typeof fetch;
}) {
const stripe = connect(opts.key, opts.fetch ?? fetch);
return (
service({
id: 'billing.stripe.example',
name: 'Billing (Stripe)',
summary:
'Customers, refunds and cancellations on our Stripe account. ' +
'Refer to customers by name, email or cus_ id. ' +
"Refunds and immediate cancellations can't be undone.",
trust: opts.trust,
})
// Replaces three GETs: customers/search, subscriptions and charges.
.ask('billing.customer', {
summary: "A customer's subscription and recent payments",
params: { who: 'string — name, email or cus_ id' },
run: ({ params }) => customerOverview(stripe, params.who),
})
// Replaces those GETs, the agent's own math, and POST /v1/refunds.
.intent('billing.refund', {
summary: 'Refund a payment (irreversible)',
params: {
who: 'string',
'payment?': 'string — ch_ id; default: the latest',
'amount?': 'number — a partial refund, in major units',
},
risk: 'medium',
plan: ({ params }) =>
one(stripe, params.who, (c) => refundPlans(stripe, c, params)),
})
// Replaces two endpoints the agent had to tell apart: a reversible POST
// and a final DELETE. Here they're two plans that say which is which.
.intent('billing.cancel', {
summary: 'Cancel a subscription',
params: { who: 'string' },
risk: 'low',
plan: ({ params }) =>
one(stripe, params.who, (c) => cancelPlans(stripe, c)),
})
);
}
/** The only code that speaks REST. Stripe errors become errors that teach. */
function connect(key: string, f: typeof fetch) {
return async function stripe<T = unknown>(
method: 'GET' | 'POST' | 'DELETE',
path: string,
body?: Record<string, string>,
idempotencyKey?: string,
): Promise<T> {
const res = await f(API + path, {
method,
headers: {
authorization: `Bearer ${key}`,
...(body
? { 'content-type': 'application/x-www-form-urlencoded' }
: {}),
...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
},
body: body ? new URLSearchParams(body).toString() : undefined,
});
const json: unknown = await res.json();
if (res.ok) {
return json as T;
}
const { error } = json as { error?: { message?: string } };
throw teach(res.status, error?.message ?? `Stripe returned ${res.status}`);
};
}
type Stripe = ReturnType<typeof connect>;
function teach(status: number, message: string) {
if (status === 404) {
return new YeaError('not_found', message, {
fix: [fix('ASK billing.customer with a name or email')],
});
}
if (status === 429) {
return new YeaError('limit', 'Stripe is rate limiting; retry shortly', {
retry: 2,
});
}
if (status >= 500) {
return new YeaError('unavailable', message, { retry: 5 });
}
// Params were validated before any REST call, so a 4xx here means the
// state changed underneath us.
return new YeaError('conflict', message);
}
/**
* People say "Chen" or an email, not cus_NffrFeUfNV2Hib. Stripe's exact match
* on a string field matches any record containing the words, so "Chen" finds
* "Chen Wei".
*/
async function findCustomers(stripe: Stripe, who: string) {
if (/^cus_\w+$/.test(who)) {
return [await stripe<Customer>('GET', `/customers/${who}`)];
}
// Stripe wants double-quoted, backslash-escaped strings.
const q = JSON.stringify(who);
const query = new URLSearchParams({
query: `name:${q} OR email:${q}`,
limit: '5',
});
return (await stripe<List<Customer>>('GET', `/customers/search?${query}`))
.data;
}
const label = (c: Customer) => `${c.name} <${c.email}>`;
const noMatch = (who: string) =>
new YeaError('not_found', `no customer matches ${JSON.stringify(who)}`, {
fix: [fix('try their email address')],
});
/** For an ASK: an ambiguous name is an error with one fix per candidate. */
async function findOne(stripe: Stripe, who: string) {
const m = await findCustomers(stripe, who);
if (!m.length) {
throw noMatch(who);
}
if (m.length > 1) {
throw new YeaError(
'invalid_params',
`${m.length} customers match ${JSON.stringify(who)}`,
{ fix: m.map((c) => fix(`use ${label(c)}`, { who: c.id })) },
);
}
return m[0];
}
/** For an INTENT: an ambiguous name gets CLARIFY, one option per candidate. */
async function one(
stripe: Stripe,
who: string,
then: (c: Customer) => Promise<Plan | Plan[]>,
) {
const m = await findCustomers(stripe, who);
if (!m.length) {
throw noMatch(who);
}
if (m.length > 1) {
return clarify(
`${m.length} customers match "${who}". Which one?`,
m.map((c) => ({ label: label(c), params: { who: c.id } })),
);
}
return then(m[0]);
}
const charges = async (stripe: Stripe, c: Customer) =>
(
await stripe<List<Charge>>(
'GET',
`/charges?${new URLSearchParams({ customer: c.id, limit: '5' })}`,
)
).data;
const activeSub = (c: Customer) =>
new URLSearchParams({ customer: c.id, status: 'active', limit: '1' });
const subscription = async (stripe: Stripe, c: Customer) =>
(await stripe<List<Subscription>>('GET', `/subscriptions?${activeSub(c)}`))
.data[0] ?? null;
// Since API version 2025-03-31, the billing period is on the subscription item.
const period = (s: Subscription) => ({
start: s.items.data[0].current_period_start,
end: s.items.data[0].current_period_end,
});
// #region ask
async function customerOverview(stripe: Stripe, who: string) {
const c = await findOne(stripe, who);
const [sub, chs] = await Promise.all([
subscription(stripe, c),
charges(stripe, c),
]);
const price = sub?.items.data[0].price;
return {
id: c.id,
name: c.name,
email: c.email,
plan: price ? (price.nickname ?? price.id) : 'none',
...renewal(sub),
// Flat rows with only what an agent needs, so Lens renders a table.
payments: chs.map((ch) => ({
id: ch.id,
date: day(ch.created),
amount: amt(ch.amount, ch.currency),
refunded: amt(ch.amount_refunded, ch.currency),
status: ch.status,
})),
};
}
// #endregion ask
/** When the current period ends: "renews" on that date, or "cancels". */
function renewal(sub: Subscription | null) {
if (!sub) {
return {};
}
const key = sub.cancel_at_period_end ? 'cancels' : 'renews';
return { [key]: day(period(sub).end) };
}
async function refundPlans(
stripe: Stripe,
c: Customer,
params: { payment?: string; amount?: number },
): Promise<Plan | Plan[]> {
const [chs, sub] = await Promise.all([
charges(stripe, c),
subscription(stripe, c),
]);
const ch = refundable(chs, c, params.payment);
const left = ch.amount - ch.amount_refunded;
const refund = refunder(stripe, c, ch);
if (params.amount != null) {
return refund(partial(params.amount, left, ch.currency), 'partial');
}
// The choices a person would offer: all of it, or the unused part.
const plans = [refund(left, 'full')];
const unused = sub && ch.id === chs[0]?.id ? unusedPart(left, sub) : null;
if (unused) {
plans.push(refund(unused.amount, `unused ${unused.days} days`));
}
return plans;
}
/** The requested charge, or else the latest one with something to refund. */
function refundable(chs: Charge[], c: Customer, payment?: string) {
const ch = payment
? chs.find((x) => x.id === payment)
: chs.find((x) => x.status === 'succeeded' && x.amount_refunded < x.amount);
if (ch) {
return ch;
}
throw new YeaError(
'not_found',
`no refundable payment${payment ? ` ${payment}` : ''} for ${c.name}`,
{
fix: chs.map((x) =>
fix(`use ${x.id} (${day(x.created)}, ${amt(x.amount, x.currency)})`, {
payment: x.id,
}),
),
},
);
}
/** A partial refund, converted to minor units and checked against `left`. */
function partial(major: number, left: number, cur: string) {
const amount = Math.round(major * 100);
if (amount > 0 && amount <= left) {
return amount;
}
throw new YeaError(
'invalid_params',
`refund must be between 0.01 and ${amt(left, cur)}`,
{
fix: [fix(`refund the rest (${amt(left, cur)})`, { amount: left / 100 })],
},
);
}
/** The part of `left` that pays for the rest of the period, if it's less. */
function unusedPart(left: number, sub: Subscription) {
const { start, end } = period(sub);
const now = Date.now() / 1000;
const amount = Math.round(left * Math.max(0, (end - now) / (end - start)));
if (amount <= 0 || amount >= left) {
return null;
}
return { amount, days: Math.round((end - now) / 86400) };
}
// #region refund-plan
function refunder(stripe: Stripe, c: Customer, ch: Charge) {
return (amount: number, why: string): Plan => ({
summary:
`Refund ${amt(amount, ch.currency)} of ${ch.id} to ${c.name}` +
` (${why})`,
effects: [
update(
`charge/${ch.id}`,
'amount_refunded',
amt(ch.amount_refunded, ch.currency),
amt(ch.amount_refunded + amount, ch.currency),
),
send(c.email, `refund receipt; back on the card in 5–10 days`),
],
cost: money(amount, ch.currency.toUpperCase()),
// YEA runs apply() at most once; the key covers a network retry.
apply: () =>
stripe(
'POST',
'/refunds',
{
charge: ch.id,
amount: String(amount),
reason: 'requested_by_customer',
},
`yea-refund-${ch.id}-${ch.amount_refunded}-${amount}`,
),
// No revert: Stripe can't reverse a refund, so the plan says
// "undo: never" and YEA never commits it automatically.
});
}
// #endregion refund-plan
async function cancelPlans(
stripe: Stripe,
c: Customer,
): Promise<Plan | Plan[]> {
const sub = await subscription(stripe, c);
if (!sub) {
throw new YeaError('conflict', `${c.name} has no active subscription`);
}
const { end } = period(sub);
const path = `/subscriptions/${sub.id}`;
const now: Plan = {
summary: `Cancel ${c.name} now; access ends immediately, no refund`,
effects: [
update(`subscription/${sub.id}`, 'status', sub.status, 'canceled'),
],
risk: 'medium',
apply: () => stripe('DELETE', path), // no inverse call exists, so no revert
};
if (sub.cancel_at_period_end) {
return now;
}
// #region cancel-plan
const atPeriodEnd: Plan = {
summary: `Cancel ${c.name} on ${day(end)}; access until then`,
effects: [
update(`subscription/${sub.id}`, 'cancel_at_period_end', false, true),
],
// Whole days, so it reads "undo: 13d" and ends before the period does.
undoWindow: roundDown(end - Date.now() / 1000),
apply: () => stripe('POST', path, { cancel_at_period_end: 'true' }),
// The inverse REST call.
revert: () => stripe('POST', path, { cancel_at_period_end: 'false' }),
};
// #endregion cancel-plan
return [atPeriodEnd, now];
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { listen } = await import('@yea-protocol/sdk/node');
const key = process.env.STRIPE_SECRET_KEY;
if (!key) {
throw new Error(
'set STRIPE_SECRET_KEY (a test-mode sk_test_… key is fine)',
);
}
const trust = (process.env.YEA_TRUST ?? '').split(',').filter(Boolean);
await listen(stripeBilling({ key, trust }), { port: 7453 });
console.error('billing (Stripe) on yea://127.0.0.1:7453');
}Checklist
- Each capability is something a user would ask for
- Names and emails work wherever ids do
- Each
ASKanswers a whole question in one call -
apply()makes the REST call;revert()reverses it, or doesn't exist - Every plan lists all its effects and its real cost
- Choices are separate plans, safest first
- Every error says how to fix the request