UserSay/ Docs

Actions — what Sarah can do

How Sarah goes from talking to doing — granting credits, building a checkout, issuing a refund — by calling a small MCP server you run and control.

By default, Sarah can only talk. She can unstick a confused user, draft a post for them to share, or hand over a link your product already makes — all just by being who she is. None of that touches an account.

An Action is the moment that changes. It's how Sarah goes from talking about something to doing it: adding credits so a blocked user keeps going, building a checkout link with a trial already applied, issuing a refund at the door. Same conversation, but now something actually happens to the user's account.

Actions are the Layer 1 half of how much you have to build. This page is the whole story: what to build, how to build it safely, and how to connect it.


Two layers: her words, and her hands

Everything Sarah can do falls into two layers, and the line between them is the line between "free" and "needs a little engineering."

Layer 0 — her wordsLayer 1 — her hands
What she doesPersuades, answers, writes tailored copy, hands over a link you already haveAdds credits, builds a checkout, applies a discount, issues a refund, counts a share
What it costs youNothing — it's just her personaA small server you run, with your own limits and safeguards
AvailableDay oneOnce you connect an Action MCP

Layer 0 is most of the value, and it's free. You only cross into Layer 1 the moment you want Sarah to give, charge, verify, or count something automatically. Actions are Layer 1.


The mechanism: you run a server, Sarah asks it

An Action MCP is a small MCP server that you run and expose a few tools on — say create_checkout_link, grant_credits, process_refund. You connect its URL in your UserSay dashboard. From then on, Sarah can call those tools mid-conversation, whenever what the user actually needs calls for one.

This is the reverse of the read MCP that exposes UserSay's data to your own coding agent. Here, UserSay connects out to you — Sarah is the client, your server is in charge. Two MCP servers pointing opposite ways; worth keeping straight.

Three things stay true by design, and they're the whole reason this is safe to turn on:

  • Your server owns every guardrail. Amount caps, who's eligible, how often, idempotency — all decided in your code, server-side. Sarah can't set an amount or override eligibility. She can only ask; your server answers yes or no. Treat anything she or the user says as a request, never as permission.
  • Identity is resolved on your side. UserSay injects the current end-user's ID into a header you choose. Your server keys the action off that — never off anything Sarah or the user typed. So Sarah never has to ask "what's your account email?", and a user can't act on someone else's account by naming it.
  • Sarah is a relay, not an authority. She never tells the user something is done until your tool says so. Your tool's job is to return a clear outcome — done, already done, or rejected, because… — and Sarah relays exactly that. On a rejection she backs off gracefully; she never invents a confirmation. This is also why there's no pre-check tool: the outcome is the eligibility answer.

She carries all of them, in every conversation

A tool is not attached to a Moment. Once your server is connected, every tool on it is available in every chat — one that opened at welcome, one that opened at cancel, one that came through your feedback button. There is no per-Moment allow-list, and there won't be one.

That's deliberate. A Moment tells you what state the user was in when she arrived. It tells you nothing about what they'll turn out to want. A chat that opened because someone looked stuck routinely ends with them asking to cancel; a welcome chat turns into a pricing question three messages in. If her toolkit changed by Moment, she'd have to meet those with "I can't do that here" — which is the same brush-off as being sent to a settings page, and the reason this product exists.

What the Moment shapes is her judgment — which move is worth making, and whether to make one at all. That's the job of a play, not of a permission list. The three guardrails above are what keep it safe, and they hold identically no matter which Moment she came in on.


How to build and connect one

1. Expose a few tools

Stand up an MCP server (any stack) reachable over HTTPS, exposing the tools you want Sarah to be able to call. Each tool is a normal MCP tool with a name, a description Sarah reads to decide when to use it, and an input schema.

Two names are reserved because Sarah uses them internally: complete_interview and lookup_knowledge.

Choose the names deliberately — they do more than label. Sarah reads a tool's name and description to decide when to call it, and a name containing refund, cancel, pause, extend, discount, grant, credit, checkout, offer, or upgrade also loads the matching playbook: the specific discipline for that kind of move, like diagnose before refunding or never lead with a discount. Name a tool something oblique and it still works, but Sarah handles it with generic care instead of the playbook written for it.

2. Put the guardrails inside the tool

Every tool you expose is a public-facing endpoint that an AI will call on a user's behalf, so each one must, on its own:

  • Check eligibility against your own records, keyed off the injected identity header — not off arguments Sarah passes.
  • Enforce its own caps — max amount, max frequency, who qualifies.
  • Be idempotent. Derive a key from the user plus the conversation so that running the same action twice can't grant or charge twice. Sarah may retry; your server must not double-act.
  • Return a clear outcome — done, already done, or rejected, because… — so Sarah can relay the truth instead of guessing.

If you bill through Stripe (or similar), the part that moves money is usually the easy part — one call. A checkout link is checkout.sessions.create({ mode: 'subscription', subscription_data: { trial_period_days }, discounts: [{ coupon }] }), which hands back a url; a retention discount is subscriptions.update(id, { discounts: [{ coupon }] }); a refund is refunds.create({ charge }), and Stripe even takes an idempotency key for you. The real work is the wrapper around that call: mapping the injected user ID to your Stripe customer and subscription, and enforcing your own caps before you make it.

3. Connect it in the dashboard

In Settings → Developers, point UserSay at your server:

  • URL — your https:// endpoint. We reject http://, loopback, .internal/.local, and private IP ranges as a first guard against SSRF, and refuse redirects on the connection.
  • Auth — a Bearer token (sent as Authorization: Bearer …), and/or any custom headers your server expects (for example a shared Api-Secret).
  • Identity header — the header name UserSay should inject the current end-user's ID into at call time (the uid you pass to UserSay.identify()). Leave it blank only if your server infers identity another way.
  • Test connection — UserSay connects, lists your tools, and shows what it found, so you can confirm Sarah will see the right set before going live.

Every call Sarah makes to your server is logged, so you can see exactly what she invoked and when.


What each stage needs from you

Before you build anything, it's worth seeing how little you have to. Every stage of the user journey already works the day you paste the script tag. A tool only buys you the part where something has to actually happen to an account.

StageWorks with no serverWhat a tool addsWhich tool
01 Get them startedTell stuck-on-the-UI from stuck-on-the-idea, and answer it on the spotTop up someone who has produced nothing yetgrant_credits
02 Stop them leavingFind the real reason; close a gap that was never real; let the genuinely-done go wellPerform the save instead of describing itpause_subscription · extend_trial · apply_discount_to_subscription · process_refund · grant_credits
03 Grow the accountAnswer the pricing question; judge whether this is a trial case, a discount case, or someone who has outgrown their planHand a no-card user one pre-loaded link; move a card-on-file user up on the spotcreate_checkout_link · apply_discount_to_subscription · grant_credits
04 Learn from every chatAll of it—none, ever

The colours aren't categories so much as a ladder — they run in the order of what the move costs you and how hard it is to take back, and they mean the same thing on every page of these docs.

Givegrant_credits

They get something extra. No money moves, and you can cap it in your own ledger.

Easecreate_checkout_link

The terms get easier — less to pay, or longer to decide. You give up revenue later, not now.

Settleprocess_refund

Money goes back, or the relationship ends. It costs cash today and the user can't undo it.

Read-only tools such as lookup_knowledge stay uncoloured because they do not change the account or its balance.

Four things are worth reading off it.

Stage 02 is where to build first, if you build at all. It's the only stage where the right move is regularly a lever rather than a sentence — someone leaving over timing needs a pause, and no amount of good conversation is one. Everywhere else, words carry most of the distance.

Stage 03 is two different jobs, and they take different tools. Converting someone who has never paid means handing over a link, because Sarah can't take a card in a chat. Growing someone who already pays means changing the subscription they're already on — no checkout, no card, genuinely one click from their side. create_checkout_link does the first, apply_discount_to_subscription does the second, and neither substitutes for the other. Expansion is usually the bigger number of the two, and it's the one people forget to build.

grant_credits appears in every row but the last. That's why it's the most reused tool in the catalog, and usually the cheapest to build: no payment provider involved, just a row in your own ledger. It's rarely the headline move — it's what makes the next one land, whether that's a trial user retrying instead of leaving or a power user hearing an upgrade pitch after you already saved their evening.

Stage 05 never needs a tool, and it improves on its own. Every conversation is mined afterwards whichever Moment opened it — so each new Moment you enable makes Understand sharper without you building or configuring anything.


The minimum viable action server

Four actions cover almost every play in Keep and Grow. Ship these and Sarah can do real work at every money moment on the site.

One constraint shapes them: Sarah lives in a chat and cannot collect a credit card. So an action for a user without a card returns a link (checkout with the incentive baked in); an action for a user who already has a card is a direct change to their account. One click either way, nothing to paste.

Pick names that fit your codebase — just keep the kind-of-move word in them (see above), and keep the shape.


Why first: the only action that works for a user with no card on file, which is most of who Sarah talks to. It unlocks Card-on-file trial and Discount with a reason.

input  : { plan: "pro" | "max", trial_days?: number, coupon?: string }
output : { url: string } | { error: "ineligible" | "already_subscribed" }

Guardrails (server-side):

  • Refuse if the user is already on plan or higher.
  • Only allow coupon values you've created server-side — never accept an arbitrary string.

Stripe:

stripe.checkout.sessions.create({
  mode: "subscription",
  customer: stripeCustomerId,            // from your DB, keyed off the injected uid
  subscription_data: { trial_period_days: trial_days },
  discounts: coupon ? [{ coupon }] : undefined,
  line_items: [{ price: priceIdFor(plan), quantity: 1 }],
  success_url, cancel_url,
})

grant_credits — the swiss-army tool

Why second: the most reused action in the catalog — powers Bootstrap a creditless start, and the make-good when something breaks.

input  : { amount: number, reason: string }
output : { status: "applied" | "already_done", new_balance: number }
        | { error: "cap_reached" | "rate_limited" }

Guardrails (server-side):

  • Cap per reason. A user can only collect each reason once (or once per N days). The reason field is how the server rate-limits future asks from Sarah.
  • Cap per user per week. Even across reasons, never grant more than $X / week.
  • Idempotent on (uid, conversation_id, reason) — Sarah may retry; the server must not double-grant.

Implementation: a single SQL transaction — read balance, check cap, write credit_ledger row, return new balance. No Stripe call needed.


apply_discount_to_subscription — the save and upsell

Why third: the one-click move for users who already have a card on file. Powers Save a saveable cancel and Upsell a power user.

input  : { coupon: string, reason: "retention" | "upsell" }
output : { status: "applied", effective_at: string }
        | { error: "ineligible" | "already_discounted" }

Guardrails:

  • Whitelist coupon values (same as checkout) and pair each with allowed reasons.
  • Refuse if the subscription already has an active discount, unless the new one strictly improves it.

Stripe:

stripe.subscriptions.update(subscriptionId, { discounts: [{ coupon }] }, {
  idempotencyKey: `disc:${uid}:${conversationId}:${reason}`,
})

process_refund — the cheaper-than-a-chargeback tool

Why fourth: powers Handle a refund or cancel. A small refund is mathematically cheaper than the dispute it prevents.

input  : { charge_id?: string, reason: string }
output : { status: "refunded", amount_cents: number, refund_id: string }
        | { status: "already_refunded" | "rejected", reason: string }

Guardrails — the whole policy lives here, never in a pre-check tool:

  • Hard cap — auto-refund up to $X per user per 90 days. Above the cap: return rejected: "above_auto_cap" and let Sarah route to a human.
  • Window — only refund charges within the last N days.
  • Abuse pattern — reject if user has > K prior refunds.
  • Preview the outcome. Many monthly-plan refunds come back at $0 because the active month is counted as used. The tool returns the real number; Sarah relays exactly that.

Stripe:

stripe.refunds.create({ charge: chargeId }, {
  idempotencyKey: `refund:${uid}:${chargeId}`,
})

That's the whole MVP

Those four cover nearly every play across Keep and Grow. Notice what's not here:

  • No check_offer / check_eligibility. Each action checks itself and returns applied, already done, or rejected, because… — Sarah just relays the result. A pre-check tool would put your eligibility rules in two places, and the two copies drift. The outcome is the eligibility answer.
  • No separate "cancel" tool — unless you hand her your cancel button. For the four plays above, cancellation is a side-effect of refund or pause. But if you wire the cancel handoff, where clicking Cancel subscription opens Sarah instead of your confirm dialog, then finishing the cancellation is the one outcome she must be able to complete herself — build cancel_subscription before you flip that button over. Naming it with cancel loads the diagnose-first playbook automatically.

One tool can do more than one thing — Diffus exposes a single cancel_user_subscription_and_refund that does both atomically. What matters is that each tool owns its own rules, and that its name says what kind of move it is.

Which action to fire, and when

This page is about building the four actions. For when to reach for which one — the trial-vs-discount-vs-credits judgment — that lives with each layer: Grow for offers, Keep for saves.


Next: Grow — how Sarah pairs these tools with the moment a user is deciding whether to pay.

On this page