Topic 14.6
Build: Mitra Handles a Multi-Step Refund
In one line
Mitra learns a real multi-step job: refund an order. A server-owned plan shows progress, reads run on their own, the money step waits for the user's approval, a Stop button works, ineligible orders end early, and the user gets a receipt and a list of what happened that they can audit.
Think of it like this
A shop assistant handling a return at the counter. They look up the receipt, check the date, ask you to sign for the refund, press the button on the till, and hand you a slip that says what was done. If the item is too old, they say so before anyone signs anything. Mitra now works the same way: look up, check, ask, act, and give you the slip.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Refund agent
- Mitra's agent for the refund job: a
ToolLoopAgentwith four tools and one approval gate. - Plan part
- The
data-planmessage part with a stableidthat the server updates as steps finish. - Skipped
- A plan step that was never needed, such as the refund for a cancelled order.
- Approval card
- The UI that shows the server's reason for a step and the Approve and Deny buttons.
- Receipt card
- The summary of the refund taken from the tool's output: refund id and amount.
- Audit list
- The "What happened" list: each step, its outcome and who decided.
- Run state
- What the server remembers about the run: plan states and whether the refund happened.
Step by step
01Start from Phase 13 and change the route
The route no longer calls streamText. It builds a ToolLoopAgent for each request, with tools that report to a plan. The run state (plan states, whether the refund happened) lives in a store keyed by chat id, because an approval ends the first request and a second one continues it.
const run = runs.get(id) ?? runs.set(id, { states: {}, refunded: false }).get(id); // your database in a real app
const stream = createUIMessageStream({
originalMessages: messages, // so the continued message keeps its id
execute: async ({ writer }) => {
const agent = new ToolLoopAgent({ model, tools, toolApproval, stopWhen: stepCountIs(8) });
writer.merge(await createAgentUIStream({ agent, uiMessages: messages }));
},
});02Reads run, changes wait
toolApproval: { issueRefund: (input) => ({ type: "user-approval", reason: … }) } builds the sentence from the order table: item, amount, where the money goes. sendReceipt checks the run state itself.
Ending early is the server's decision too: when the window check finds a cancelled order, it marks the refund and receipt steps skipped and the agent ends with the reason. No approval card ever appears for an order that cannot be refunded.
03Three runs on the server
The first live example drives the route with three requests and prints the plan and the ending of each. Run one is approved. Run two is for the cancelled order. Run three is refused by the user.
Read the plan lines: the approved run ends with every step done, the cancelled order ends with refund=skipped receipt=skipped, and the refused run never reaches the receipt.
04The screen
The second live example is Mitra as it stands after this phase. A message shows: the plan as a checklist, the approval card with Approve and Deny while a refund waits, the receipt card, Mitra's sentence, and a "What happened" list. The form keeps the ids from earlier phases: #prompt, #send, #stop, #status, #error.
Stop is a real button: it is enabled while a run is going and calls chat.stop(). The run state on the server and the ledger idea from Topic 14.3 are what a production version would use to say exactly what had happened.
05What to do next on your own
Add the resume route from Topic 14.2 so a user who closes the tab during the approval finds the card again. Add onStepEnd logging from Topic 14.1 to save each step. Both are small changes because the run state is already on the server.
Try it yourself
- 1
Ask for the cancelled order first
In the first example, swap the first two lines of
SCRIPTso the cancelled order comes first.No approval card appears for it, and the approved refund after it still completes normally, because each new question starts a new run.
- 2
Remove the receipt rule
In the first example, change the
sendReceipttool's body topublish("receipt", "done"); return { sent: true };, and make the scripted model skip its denied-refund check by deleting theexecution-deniedline.On the refused run, the receipt step now says
doneand an email is sent for a refund that never happened (see the Break it box for the exact lines). A rule written in a prompt, or hoped for from the model, would fail the same way. - 3
Change the price in the order table
In the second example, change
total: 349tototal: 299for ORD-1042 and run it again.The approval card, the receipt card and Mitra's sentence all say Rs 299, because all three read the same table. None of them depends on the model's wording.
Code & diagrams
Three questions in one chat. The approved run completes every step. The cancelled order skips its last two and never shows an approval card. The refused run shows the card, takes the no, and the receipt never happens.
Preview
Console
Nothing logged yet.Expected console output
card: Refund Rs 349 for ORD-1042 (Masala chai 250 g) to the original payment method
run 1: lookup=done window=done refund=done receipt=done
Mitra: Done. Rs 349 is on its way back to your original payment method.
run 2: lookup=done window=done refund=skipped receipt=skipped
Mitra: ORD-1044 is cancelled, so there is nothing to refund.
card: Refund Rs 349 for ORD-1042 (Masala chai 250 g) to the original payment method
run 3: lookup=done window=done refund=refused receipt=skipped
Mitra: Okay. I have not refunded anything.The scripted visitor types a question, presses the real Approve button, then asks about a cancelled order. Try your own: ORD-1042 or ORD-1044, then Stop, Approve and Deny.
Preview
Console
Nothing logged yet.Expected console output
run 1: lookup=done window=done refund=done receipt=done
receipt card: RF-1042, Rs 349
Mitra: Done. Rs 349 is on its way back to your original payment method.
run 2: lookup=done window=done refund=skipped receipt=skipped
receipt card: none
Mitra: ORD-1044 is cancelled, so there is nothing to refund.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Let the model decide the receipt
In the first example, replace the body of sendReceipt's execute with publish("receipt", "done"); return { sent: true }; and delete the line if (seen("issueRefund").output.type === "execution-denied") return "Okay. I have not refunded anything.";.
Myth vs fact
Myth
If the plan shows every step green, the job is right.
Fact
The plan shows which steps ran. Whether they were right depends on the tools' own rules, and on a receipt that matches the order table. Check the outputs, not only the colours.
Myth
The approval card can be built from what the model says.
Fact
Build it from your own order data. A model can misquote an amount or an item, and the user is about to authorise exactly what the card says.
Myth
An audit list is a log for developers.
Fact
Here it is a user feature. It lets the person see what happened, in order, who decided, and what did not happen.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Store the run state in a database row, keyed by chat id: plan states, approval decisions and results. The same row powers the resume route (Topic 14.2), the stop ledger (Topic 14.3) and the audit list, so write it once.
- ▸
Make
issueRefundidempotent with a key made from the order id and the approval id. If the second request after an approval is retried, a second refund must not happen. - ▸
Test this feature with the scripted model, like any other (Topic 13.6): one test per ending, asserting the plan states, the card text and that no email goes out after a refusal.
- ▸
Keep the cost in view: a refund run is about five model calls. Log it under its own label (Topic 13.5) and apply the daily limit (Topic 13.2) to runs, not only to messages.
Remember this
- 1
The problem this solves: Until now Mitra could answer questions and run single tools. A refund is a chain: each step depends on the one before, one step moves money, and the user needs to see all of it. This topic puts the whole of Phase 14 into one feature: a plan, live progress, an approval gate, a stop, a clear ending for every case, and a record to audit.
- 2
Where Mitra starts. The Phase 13 Mitra is a
useChatchat on/api/chatwith the ids#messages,#prompt,#send,#stop,#errorand#status, plus a usage meter, a daily limit, a model fallback, metrics and tests. In a real product all of that stays. To keep the live example short enough to read, it shows only the chat core and the new refund feature, and leaves the Phase 13 meter, limit and fallback out. This topic changes the route (an agent instead of a singlestreamText) and what a message can show (a plan, an approval card, a receipt, an audit list). - 3
The agent's four tools, split by risk.
lookupOrderandcheckReturnWindowonly read, so they run at once.issueRefundchanges money, so it is behindtoolApprovalwith a reason that the server builds from its own order data.sendReceiptsends an email, which cannot be recalled, and has a rule in its own code: no refund, no receipt. The server also owns the plan: four steps sent as onedata-planpart that is updated as the steps finish (Topic 14.1). - 4
Every ending has a sentence. *Normal:* the refund is done, a receipt card shows the refund id and the amount, and Mitra says so. *Not eligible* (a cancelled order): the window check ends the plan early, the later steps show skipped, and no approval is ever asked. *Refused:* the user denies the refund, the step shows "refused by you", the receipt is skipped by the server's rule, and Mitra says nothing was refunded. *Stopped:* the Stop button ends the run and the audit list shows what had finished. *Failed or returned later:* a step fails, or the user comes back after closing the tab (Topic 14.2), and the stored status decides what the screen says.
- 5
The record the user can audit. After the run, a "What happened" list shows each step in order with its outcome, and who decided ("You approved: Refund Rs 349 for ORD-1042…"). It is built from the message's own parts, which the server also stores, and it uses no times from the model. The receipt card shows the refund id and amount that came from the tool's output, not from Mitra's wording. An auditor can compare the card, the list and the order table, and they must agree.
- 6
What Mitra can do now that it could not before: run a multi-step job with a visible plan, ask for approval with real details before moving money, end early with a clear reason, be stopped, and leave a receipt and an audit trail. What is still missing: the whole assistant has not yet been assembled and checked end to end, and its answers have not been reviewed with a code-review checklist. Phase 15 does both in the capstone. A real deployment would also store runs in a database and use a real model and payment system, which sit behind the same routes.
Explain it without notes
Walk through the refund feature: which steps run without asking, which wait, and where does each rule live?
Why does the run state live on the server and not in the browser?
What does the screen show for a cancelled order, and why is no approval asked?
How can a user audit what Mitra did?
Practice
Add a fifth plan step, "Update the loyalty points", that runs after the refund and is skipped when the refund was refused. Say which three places in the code change.
Write the test list for this feature: one line per ending, and what each asserts.
Trade-offs
- ↔
Doing the refund in one agent with approval gates is simple to reason about, but a long-running real payment system would use a background job and notify the user when it finishes (Topic 14.2).
- ↔
A server-owned plan is accurate but must be kept in step with the tools. A plan generated by the model is flexible but can be wrong. For money, choose accurate.
Done when you can
I can describe the refund agent's tools by risk and where each rule lives.
I can show a server-owned plan, an approval card, a receipt card and an audit list.
I can give every ending (approved, ineligible, refused, stopped, failed, returned later) its own sentence.
I can keep dependent rules in tool code instead of the prompt.
I can say what Mitra can do now and what Phase 15 still adds.