Skip to content

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

http
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=2287

Nothing 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

text
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 USD

The 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 APIIn YEA
Several GETs that answer one questionOne ASK
A POST, PATCH or DELETEAn INTENT that returns plans, then COMMIT
The write call itselfThe plan's apply()
The call that reverses it, if anyThe 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 jobTwo plans from one intent
Ids like cus_NffrFeUfNV2HibNames or emails, resolved by your service
4xx errorsErrors 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 saysStripe endpoints todayYEA
"What's going on with Chen's account?"GET customers, subscriptions, chargesASK billing.customer {who}
"Refund Chen's last payment"the three GETs, then POST /v1/refundsINTENT billing.refund {who}
"Cancel Chen at the end of the month"POST /v1/subscriptions/:id cancel_at_period_end=trueINTENT billing.cancel {who}, plan 1
"Cancel Chen right now"DELETE /v1/subscriptions/:idINTENT billing.cancel {who}, plan 2
"Actually, keep Chen's subscription"POST /v1/subscriptions/:id cancel_at_period_end=falseUNDO 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

json
{ "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

text
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, not 4900.
  • 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
ts
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 fieldWhat goes in itFor a refund
summaryOne line a person would readRefund 22.87 USD of ch_2 to Chen Wei
effectsEverything the call changes, including emailsthe charge, and the receipt email
costThe money it moves22.87 USD
apply()The REST callPOST /v1/refunds
revert()The call that reverses it, if one existsnone, so undo: never
ts
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:

ts
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 ​

WhenYEA errorInclude
Params are out of rangeinvalid_paramsA fix: refund the rest (49.00 USD) → {"amount":49}
A name matches several recordsCLARIFYOne option per match
404not_foundWhich ASK would find it
429 or 5xxlimit or unavailableWhen 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:

sh
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 it

The playground runs the same design with made-up data, no key needed.

Show the full file
ts
/**
 * 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 ASK answers 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