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 tool answers with ready-made plans: what will happen, what it costs, and whether it can be undone. The person approves a plan, or their signed policy lets it run. This page shows how to turn a REST API into that, using a Stripe-backed billing API as the example.

The example is an MCP server built with @yea-protocol/mcp (Add YEA to your MCP server). If you're implementing the YEA protocol itself, the same design as a protocol service is at the end.

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 the person picks a plan

text
refund {"who": "Chen"}

Approval needed: refund can't be undone.

[1] 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
  uses: spend 49.00 USD · risk: medium · undo: never
[2] Refund 22.87 USD of ch_2 to Chen Wei (unused 14 days)
  …
  uses: spend 22.87 USD · risk: medium · undo: never

The person picks the second plan in the client's form and types its phrase (what that proves). A refund can't be undone, so it always asks: no policy lets it run on its own.

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). Outcome-level tools take those turns away.

The cheat sheet ​

In your REST APIWith YEA
Several GETs that answer one questionOne read-only tool
A POST, PATCH or DELETEA job() tool that returns plans
The write call itselfThe plan's apply()
The call that reverses it, if anyThe job's revert(), which makes plans with an undoWindow undoable
Side effects your docs mention ("sends a receipt")The plan's effects, shown to the agent and the person
Two endpoints for two ways of doing one jobTwo plans from one job
Ids like cus_NffrFeUfNV2HibNames or emails, resolved by your server
4xx errorsErrors that say how to fix the call, and clarify() for ambiguity

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 tool.

What the user saysStripe endpoints todayYEA
"What's going on with Chen's account?"GET customers, subscriptions, chargescustomer {who}, a read-only tool
"Refund Chen's last payment"the three GETs, then POST /v1/refundsrefund {who}, a job
"Cancel Chen at the end of the month"POST /v1/subscriptions/:id cancel_at_period_end=truecancel {who}, plan 1
"Cancel Chen right now"DELETE /v1/subscriptions/:idcancel {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 tools, and the undo tool YEA adds. Endpoints agents shouldn't touch, like API keys or webhooks, are simply left out.

2. Turn reads into one plain tool ​

A read needs no approval, so it's an ordinary MCP tool. It 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-12
payments[1]{id,date,amount,refunded}:
  ch_2,2026-09-12,49.00 USD,0.00 USD
  • Accept names and emails, and resolve them in the server.
  • Return readable values: dates, not timestamps; 49.00 USD, not 4900.
  • Keep rows flat, so they render as a table.
  • Keep lists short: the few rows an agent needs, not a page of everything.
The code for this tool
ts
/** One question, one call: replaces three GETs, and returns only what an agent needs. */
async function customerOverview(stripe: Stripe, who: string) {
  const m = await findCustomers(stripe, who);

  if (m.length === 0) {
    throw new Error(
      `no customer matches ${JSON.stringify(who)}; try their email`,
    );
  }

  // Several matches: ask which one, rather than answer about the wrong person.
  if (m.length > 1) {
    return {
      question: `${m.length} customers match "${who}". Which one? Call again with its id.`,
      customers: m.map((c) => ({ id: c.id, name: c.name, email: c.email })),
    };
  }

  const c = m[0];

  const [sub, chs] = await Promise.all([
    subscription(stripe, c),
    charges(stripe, c),
  ]);

  return {
    id: c.id,
    name: c.name,
    email: c.email,
    plan: sub?.items.data[0].price.nickname ?? 'none',
    renews: sub ? day(sub.items.data[0].current_period_end) : 'never',
    // Flat rows with readable values.
    payments: chs.map((ch) => ({
      id: ch.id,
      date: day(ch.created),
      amount: amt(ch.amount, ch.currency),
      refunded: amt(ch.amount_refunded, ch.currency),
    })),
  };
}
ts
server.registerTool(
  'customer',
  {
    description: "A customer's subscription and recent payments",
    inputSchema: who,
    annotations: { readOnlyHint: true },
  },
  async (input) => {
    const overview = await customerOverview(stripe, input.who);

    // lean() renders it the way YEA shows data to models: flat rows become a table.
    return {
      content: [{ type: 'text', text: lean(overview) }],
      structuredContent: overview,
    };
  },
);

3. Turn writes into jobs ​

A job 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
usesWhat it uses up, such as the money it movesspend 22.87 USD
apply()The REST callPOST /v1/refunds
undoWindowHow long it can be undone for, in whole seconds, with the job's revert()none, so undo: never
ts
/** A refund of `amount` (minor units) of one charge. Stripe can't reverse it. */
function refundPlan(
  stripe: Stripe,
  refund: { customer: Customer; charge: Charge; amount: number; why: string },
): JobPlan {
  const { customer: c, charge: ch, amount } = refund;

  return {
    summary: `Refund ${amt(amount, ch.currency)} of ${ch.id} to ${c.name} (${refund.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'),
    ],
    uses: {
      spend: quantity(amount, { scale: 2, unit: twoDecimal(ch.currency) }),
    },
    // Stripe replays the saved response for a key it has seen, so each apply() gets its own:
    // a later refund must never be mistaken for this one.
    apply: () =>
      stripe(
        'POST',
        '/refunds',
        { charge: ch.id, amount: String(amount) },
        `yea-refund-${randomUUID()}`,
      ),
    // No undoWindow: a refund can't be undone, so the person is always asked.
  };
}

The job ties the plans to a tool. plan() only reads: it runs on preview and on every retry, and apply() runs at most once, after the plan is approved or allowed.

ts
approvals.job(server, 'refund', {
  description: "Refund a customer's latest payment (irreversible)",
  inputSchema: who,
  risk: 'medium',
  plan: (input) =>
    oneCustomer(stripe, input.who, (c) => refundPlans(stripe, c)),
});

When a reverse call exists, the job's revert() makes it, and plans that set an undoWindow become undoable. The window is a positive whole number of seconds: with 0, a negative number or a fraction, the plan can't be undone, as with no window at all, so it never runs without asking and reads undo: never. Cancelling at the end of the month is undone by setting cancel_at_period_end back to false:

ts
const atPeriodEnd: JobPlan = {
  summary: `Cancel ${c.name} on ${day(end)}; access until then`,
  effects: [
    update(`subscription/${sub.id}`, 'cancel_at_period_end', false, true),
  ],
  // Whole days, ending before the period does; with less than a day left, no undo.
  ...(daysLeft > 0 ? { undoWindow: daysLeft * 86_400 } : {}),
  apply: async () => {
    await stripe('POST', path, { cancel_at_period_end: 'true' });

    return { subscription: sub.id }; // what revert() gets back as `result`
  },
};
ts
approvals.job(server, 'cancel', {
  description: 'Cancel a subscription, now or at the end of the period',
  inputSchema: who,
  risk: 'low',
  plan: (input) =>
    oneCustomer(stripe, input.who, (c) => cancelPlans(stripe, c)),
  // The inverse REST call. Only the undoable plan (at period end) can reach it.
  revert: ({ result }) =>
    stripe('POST', `/subscriptions/${subscriptionOf(result)}`, {
      cancel_at_period_end: 'false',
    }),
});
  • List every effect. The person approves what the plan says, so apply() must do nothing more.
  • Add revert() only if it really restores the old state. Only undoable plans can run without asking, under the person's signed policy. Everything else asks every time.
  • 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 call ​

A plan that throws becomes an error result carrying its message, and nothing runs. Make the message say what to do next.

WhenWith YEAInclude
Params are out of rangeThrow an errorThe fix: refund at most 49.00 USD
A name matches several recordsReturn clarify()One option per match
404Throw an errorWhich tool would find it
429 or 5xxThrow an errorWhen to retry

5. Skip the plumbing ​

The approval form and its typed confirmation, consent codes for clients that can't ask, the signed policy, one-time approval, preview and undo come with job(). You don't build them. See Add YEA to your MCP server.

The full example ​

examples/stripe-jobs.ts puts these three tools in front of the real Stripe API. It handles two-decimal currencies only, and refuses the rest; the @yea-protocol/stripe connector handles every Stripe currency. @yea-protocol/mcp isn't on npm yet, so run it from a clone of the repo, after npm ci && npm run build, with a test-mode key:

sh
claude mcp add billing -e STRIPE_SECRET_KEY=sk_test_… -- npx tsx examples/stripe-jobs.ts
Show the full file
ts
/**
 * The worked example in "From REST to YEA" (site/guide/service-design.md), on the framework: a
 * Stripe-backed billing API as MCP tools, where each write is a job() that returns plans.
 * examples/stripe-billing.ts is the same design as a protocol service.
 *
 *   STRIPE_SECRET_KEY=sk_test_… npx tsx examples/stripe-jobs.ts
 */
import { randomUUID } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { type Approvals, yea } from '@yea-protocol/mcp';
import {
  clarify,
  type JobPlan,
  lean,
  quantity,
  send,
  update,
} from '@yea-protocol/sdk';
import * as z from 'zod';

const API = 'https://api.stripe.com/v1';

/** The fields of Stripe's objects that this server 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 type Stripe = <T = unknown>(
  method: 'GET' | 'POST' | 'DELETE',
  path: string,
  body?: Record<string, string>,
  idempotencyKey?: string,
) => Promise<T>;

/** The only code that speaks REST. Stripe's errors become messages the model can act on. */
export function connect(key: string, f: typeof fetch): Stripe {
  return async <T>(
    method: string,
    path: string,
    body?: Record<string, string>,
    idempotencyKey?: string,
  ) => {
    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) {
      const { error } = json as { error?: { message?: string } };

      throw new Error(error?.message ?? `Stripe returned ${res.status}`);
    }

    return json as T;
  };
}

/** Currencies whose minor unit isn't a hundredth, or that Stripe treats as special cases. */
const NOT_TWO_DECIMAL = new Set(
  (
    'bif clp djf gnf jpy kmf krw mga pyg rwf ugx vnd vuv xaf xof xpf ' +
    'bhd jod kwd omr tnd isk huf twd'
  ).split(' '),
);

/** This example assumes two decimals; it refuses other currencies rather than misstate them. */
function twoDecimal(cur: string): string {
  if (NOT_TWO_DECIMAL.has(cur.toLowerCase())) {
    throw new Error(
      `${cur.toUpperCase()} isn't a two-decimal currency, and this example only handles those; the @yea-protocol/stripe connector handles every Stripe currency`,
    );
  }

  return cur.toUpperCase();
}

const amt = (minor: number, cur: string) =>
  `${(minor / 100).toFixed(2)} ${twoDecimal(cur)}`;
const day = (unix: number) => new Date(unix * 1000).toISOString().slice(0, 10);
const label = (c: Customer) => `${c.name} <${c.email}>`;

/** People say "Chen" or an email, not cus_NffrFeUfNV2Hib. */
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;
}

/** The one customer `who` names: an ambiguous name is a question back, not a guess. */
async function oneCustomer(
  stripe: Stripe,
  who: string,
  then: (c: Customer) => Promise<JobPlan[]>,
) {
  const m = await findCustomers(stripe, who);

  if (m.length === 0) {
    throw new Error(
      `no customer matches ${JSON.stringify(who)}; try their email`,
    );
  }

  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;

async function subscription(stripe: Stripe, c: Customer) {
  const q = new URLSearchParams({
    customer: c.id,
    status: 'active',
    limit: '1',
  });

  return (await stripe<List<Subscription>>('GET', `/subscriptions?${q}`))
    .data[0];
}

// #region read
/** One question, one call: replaces three GETs, and returns only what an agent needs. */
async function customerOverview(stripe: Stripe, who: string) {
  const m = await findCustomers(stripe, who);

  if (m.length === 0) {
    throw new Error(
      `no customer matches ${JSON.stringify(who)}; try their email`,
    );
  }

  // Several matches: ask which one, rather than answer about the wrong person.
  if (m.length > 1) {
    return {
      question: `${m.length} customers match "${who}". Which one? Call again with its id.`,
      customers: m.map((c) => ({ id: c.id, name: c.name, email: c.email })),
    };
  }

  const c = m[0];

  const [sub, chs] = await Promise.all([
    subscription(stripe, c),
    charges(stripe, c),
  ]);

  return {
    id: c.id,
    name: c.name,
    email: c.email,
    plan: sub?.items.data[0].price.nickname ?? 'none',
    renews: sub ? day(sub.items.data[0].current_period_end) : 'never',
    // Flat rows with readable values.
    payments: chs.map((ch) => ({
      id: ch.id,
      date: day(ch.created),
      amount: amt(ch.amount, ch.currency),
      refunded: amt(ch.amount_refunded, ch.currency),
    })),
  };
}
// #endregion read

// #region refund-plan
/** A refund of `amount` (minor units) of one charge. Stripe can't reverse it. */
function refundPlan(
  stripe: Stripe,
  refund: { customer: Customer; charge: Charge; amount: number; why: string },
): JobPlan {
  const { customer: c, charge: ch, amount } = refund;

  return {
    summary: `Refund ${amt(amount, ch.currency)} of ${ch.id} to ${c.name} (${refund.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'),
    ],
    uses: {
      spend: quantity(amount, { scale: 2, unit: twoDecimal(ch.currency) }),
    },
    // Stripe replays the saved response for a key it has seen, so each apply() gets its own:
    // a later refund must never be mistaken for this one.
    apply: () =>
      stripe(
        'POST',
        '/refunds',
        { charge: ch.id, amount: String(amount) },
        `yea-refund-${randomUUID()}`,
      ),
    // No undoWindow: a refund can't be undone, so the person is always asked.
  };
}
// #endregion refund-plan

/** The latest refundable charge: all of what's left, or the unused part of the period. */
async function refundPlans(stripe: Stripe, c: Customer): Promise<JobPlan[]> {
  const [chs, sub] = await Promise.all([
    charges(stripe, c),
    subscription(stripe, c),
  ]);
  const ch = chs.find(
    (x) => x.status === 'succeeded' && x.amount_refunded < x.amount,
  );

  if (!ch) {
    throw new Error(`${c.name} has no payment left to refund`);
  }

  twoDecimal(ch.currency);

  const left = ch.amount - ch.amount_refunded;
  const plans = [
    refundPlan(stripe, { customer: c, charge: ch, amount: left, why: 'full' }),
  ];
  const unused = sub && ch.id === chs[0].id ? unusedPart(left, sub) : null;

  if (unused) {
    plans.push(refundPlan(stripe, { customer: c, charge: ch, ...unused }));
  }

  return plans;
}

/** The part of `left` that pays for the rest of the period, in whole days. */
function unusedPart(left: number, sub: Subscription) {
  const { current_period_start: start, current_period_end: end } =
    sub.items.data[0];
  const days = Math.floor((end - Date.now() / 1000) / 86_400);
  const amount = Math.round((left * days * 86_400) / (end - start));

  return amount > 0 && amount < left
    ? { amount, why: `unused ${days} days` }
    : null;
}

/** Cancel at the end of the period (undoable), or now (not). */
async function cancelPlans(stripe: Stripe, c: Customer): Promise<JobPlan[]> {
  const sub = await subscription(stripe, c);

  if (!sub) {
    throw new Error(`${c.name} has no active subscription`);
  }

  const path = `/subscriptions/${sub.id}`;
  const end = sub.items.data[0].current_period_end;
  const daysLeft = Math.floor((end - Date.now() / 1000) / 86_400);
  const now: JobPlan = {
    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),
  };

  if (sub.cancel_at_period_end) {
    return [now];
  }

  // #region cancel-plan
  const atPeriodEnd: JobPlan = {
    summary: `Cancel ${c.name} on ${day(end)}; access until then`,
    effects: [
      update(`subscription/${sub.id}`, 'cancel_at_period_end', false, true),
    ],
    // Whole days, ending before the period does; with less than a day left, no undo.
    ...(daysLeft > 0 ? { undoWindow: daysLeft * 86_400 } : {}),
    apply: async () => {
      await stripe('POST', path, { cancel_at_period_end: 'true' });

      return { subscription: sub.id }; // what revert() gets back as `result`
    },
  };
  // #endregion cancel-plan

  return [atPeriodEnd, now];
}

/** The receipt's subscription id, as apply() returned it. */
function subscriptionOf(result: unknown): string {
  const id = (result as { subscription?: unknown } | null)?.subscription;

  if (typeof id !== 'string') {
    throw new Error('this receipt has no subscription to restore');
  }

  return id;
}

const who = z.object({ who: z.string().describe('name, email or cus_ id') });

/** The billing tools: one read, two jobs. `undo` comes with the first job that has revert. */
export function createServer(approvals: Approvals, stripe: Stripe): McpServer {
  const server = new McpServer(
    { name: 'billing', version: '1.0.0' },
    approvals.serverOptions(),
  );

  // #region read-tool
  server.registerTool(
    'customer',
    {
      description: "A customer's subscription and recent payments",
      inputSchema: who,
      annotations: { readOnlyHint: true },
    },
    async (input) => {
      const overview = await customerOverview(stripe, input.who);

      // lean() renders it the way YEA shows data to models: flat rows become a table.
      return {
        content: [{ type: 'text', text: lean(overview) }],
        structuredContent: overview,
      };
    },
  );
  // #endregion read-tool

  // #region refund-job
  approvals.job(server, 'refund', {
    description: "Refund a customer's latest payment (irreversible)",
    inputSchema: who,
    risk: 'medium',
    plan: (input) =>
      oneCustomer(stripe, input.who, (c) => refundPlans(stripe, c)),
  });
  // #endregion refund-job

  // #region cancel-job
  approvals.job(server, 'cancel', {
    description: 'Cancel a subscription, now or at the end of the period',
    inputSchema: who,
    risk: 'low',
    plan: (input) =>
      oneCustomer(stripe, input.who, (c) => cancelPlans(stripe, c)),
    // The inverse REST call. Only the undoable plan (at period end) can reach it.
    revert: ({ result }) =>
      stripe('POST', `/subscriptions/${subscriptionOf(result)}`, {
        cancel_at_period_end: 'false',
      }),
  });
  // #endregion cancel-job

  return server;
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
  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 approvals = yea({ name: 'billing', transport: 'stdio' });
  const stripe = connect(key, fetch);

  serveStdio(() => createServer(approvals, stripe));
}

Checklist ​

  • Each tool is something a user would ask for
  • Names and emails work wherever ids do
  • Each read 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 what it really uses
  • Choices are separate plans, safest first
  • Every error says how to fix the call

Implementing the protocol: the same design as a service ​

YEA is also a protocol with its own verbs, for services that agents reach directly or through the yea mcp bridge. There, the same billing API is a service() whose reads are ASKs and whose writes are INTENTs that return proposals:

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
  uses: spend 49.00 USD
[p_VPlXarXR] Refund 22.87 USD of ch_2 to Chen Wei (unused 14 days)
  …
  uses: spend 22.87 USD

The agent commits a plan with COMMIT, under the human's grant or with their consent. The design steps above carry over; only the API differs. In a protocol service, revert() sits on the plan itself:

The ASK
ts
async function customerOverview(stripe: StripeApi, 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,
    })),
  };
}
ts
function refunder(stripe: StripeApi, c: Stripe.Customer, ch: Stripe.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 ?? c.id, `refund receipt; back on the card in 5–10 days`),
    ],
    // What the refund spends, by the `spend` convention (docs/conventions.md).
    uses: {
      spend: toQuantity(amount, ch.currency),
    },
    // YEA runs apply() at most once. write() hands the call a fresh
    // idempotency key, which only its own network retries reuse: a key
    // derived from the plan would turn a deliberate second refund into a
    // silent replay.
    apply: () =>
      stripe.write((s, o) =>
        s.refunds.create(
          { charge: ch.id, amount, reason: 'requested_by_customer' },
          o,
        ),
      ),
    // No revert: Stripe can't reverse a refund, so the plan says
    // "undo: never" and YEA never commits it automatically.
  });
}
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.write((s, o) =>
      s.subscriptions.update(sub.id, { cancel_at_period_end: true }, o),
    ),
  // The inverse call.
  revert: () =>
    stripe.write((s, o) =>
      s.subscriptions.update(sub.id, { cancel_at_period_end: false }, o),
    ),
};

Errors are YeaErrors with machine-applicable fixes, and ambiguity is a CLARIFY reply. See Build a service for the library API.

examples/stripe-billing.ts runs it against the real Stripe API 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,
  type Plan,
  send,
  service,
  update,
  YeaError,
} from '@yea-protocol/sdk';
// Stripe's official SDK, through the @yea-protocol/stripe connector's client, which pins the API
// version, gives each write a fresh idempotency key, and says what went wrong. The readers
// (findCustomers, recentCharges…) are shared with the connector too.
import {
  currentSubscriptions,
  findCustomers,
  period,
  recentCharges,
  type Stripe,
  type StripeApi,
  StripeError,
  stripeApi,
} from '@yea-protocol/stripe/api';
// Stripe's currency rules: zero- and three-decimal currencies, ISK and UGX.
import {
  formatMoney as amt,
  formatNumber,
  parseMoney,
  roundDown as roundToStep,
  toQuantity,
} from '@yea-protocol/stripe/currency';

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);

export function stripeBilling(opts: {
  key: string;
  trust: string[];
  fetch?: typeof fetch;
}) {
  const stripe = connect(opts.key, opts.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 connector's Stripe client, whose errors become errors that teach. */
function connect(key: string, f?: typeof fetch): StripeApi {
  const api = stripeApi({ key, fetch: f });
  const taught = async <T>(call: () => Promise<T>): Promise<T> => {
    try {
      return await call();
    } catch (e) {
      throw e instanceof StripeError ? teach(e) : e;
    }
  };

  return {
    read: (call) => taught(() => api.read(call)),
    write: (call) => taught(() => api.write(call)),
  };
}

function teach(e: StripeError) {
  const { status, message } = e;

  // A write that may have happened must not be retried: with a fresh
  // idempotency key, a retry would be a second refund.
  if (e.unknown) {
    return new YeaError('conflict', message, {
      fix: [fix('ASK billing.customer to see whether it happened')],
    });
  }

  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 || status === 0) {
    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: findCustomers
// looks an email up exactly, and otherwise searches names and emails.

const label = (c: Stripe.Customer) => `${c.name ?? c.id} <${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: StripeApi, 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: StripeApi,
  who: string,
  then: (c: Stripe.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 = (stripe: StripeApi, c: Stripe.Customer) =>
  recentCharges(stripe, c.id, 5);
const subscription = async (stripe: StripeApi, c: Stripe.Customer) =>
  (await currentSubscriptions(stripe, c.id))[0] ?? null;

// #region ask
async function customerOverview(stripe: StripeApi, 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: Stripe.Subscription | null) {
  if (!sub) {
    return {};
  }

  const key = sub.cancel_at_period_end ? 'cancels' : 'renews';

  return { [key]: day(period(sub).end) };
}

async function refundPlans(
  stripe: StripeApi,
  c: Stripe.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, ch.currency) : 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: Stripe.Charge[],
  c: Stripe.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 Stripe's units and checked against `left`. */
function partial(major: number, left: number, cur: string) {
  let amount = 0;

  try {
    amount = parseMoney(String(major), cur);
  } catch {
    amount = 0;
  }

  if (amount > 0 && amount <= left) {
    return amount;
  }

  throw new YeaError(
    'invalid_params',
    `refund must be more than 0 and at most ${amt(left, cur)}, in amounts ${cur.toUpperCase()} allows`,
    {
      fix: [
        fix(`refund the rest (${amt(left, cur)})`, {
          amount: Number(formatNumber(left, cur)),
        }),
      ],
    },
  );
}

/** The part of `left` that pays for the rest of the period, if it's less. */
function unusedPart(left: number, sub: Stripe.Subscription, cur: string) {
  const { start, end } = period(sub);
  const now = Date.now() / 1000;
  const amount = roundToStep(
    Math.round(left * Math.max(0, (end - now) / (end - start))),
    cur,
  );

  if (amount <= 0 || amount >= left) {
    return null;
  }

  return { amount, days: Math.round((end - now) / 86400) };
}

// #region refund-plan
function refunder(stripe: StripeApi, c: Stripe.Customer, ch: Stripe.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 ?? c.id, `refund receipt; back on the card in 5–10 days`),
    ],
    // What the refund spends, by the `spend` convention (docs/conventions.md).
    uses: {
      spend: toQuantity(amount, ch.currency),
    },
    // YEA runs apply() at most once. write() hands the call a fresh
    // idempotency key, which only its own network retries reuse: a key
    // derived from the plan would turn a deliberate second refund into a
    // silent replay.
    apply: () =>
      stripe.write((s, o) =>
        s.refunds.create(
          { charge: ch.id, amount, reason: 'requested_by_customer' },
          o,
        ),
      ),
    // 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: StripeApi,
  c: Stripe.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 now: Plan = {
    summary: `Cancel ${c.name} now; access ends immediately, no refund`,
    effects: [
      update(`subscription/${sub.id}`, 'status', sub.status, 'canceled'),
    ],
    risk: 'medium',
    // No inverse call exists, so no revert. Stripe ignores idempotency keys
    // on DELETE, so it isn't retried: a lost answer is reported as unknown.
    apply: () =>
      stripe.write((s, o) =>
        s.subscriptions.cancel(sub.id, {}, { ...o, maxNetworkRetries: 0 }),
      ),
  };

  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.write((s, o) =>
        s.subscriptions.update(sub.id, { cancel_at_period_end: true }, o),
      ),
    // The inverse call.
    revert: () =>
      stripe.write((s, o) =>
        s.subscriptions.update(sub.id, { cancel_at_period_end: false }, o),
      ),
  };
  // #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');
}