1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
17 small wins to finish your path
Support desk with approval and tests
Put the course together: typed triage, an agent with lookups and refunds that need approval over a limit, a runner, and tests that check the rules.
import { generateText, Output, ToolLoopAgent, isStepCount, type ModelMessage } from "ai";
import type { LanguageModel } from "ai";
import { Ticket } from "./ticket.ts";
import { lookupOrder } from "./tools.ts";
import { refundOrder } from "./refund.ts";
export function createDesk(model: LanguageModel, refundLimit = 50) {
const agent = new ToolLoopAgent({
model,
instructions: "You answer support tickets for an online shop.",
tools: { lookupOrder, refundOrder },
toolApproval: { refundOrder: async ({ amount }) => (amount > refundLimit ? "user-approval" : undefined) },
stopWhen: isStepCount(5),
});
async function triage(ticket: string) {
const { output } = await generateText({ model, output: Output.object({ schema: Ticket }), prompt: ticket });
return output;
}
async function answer(messages: ModelMessage[]) {
const result = await agent.generate({ messages });
const approvals = result.content.filter((part) => part.type === "tool-approval-request");
return { text: result.text, approvals, responseMessages: result.response.messages };
}
return { triage, answer };
}createDesktakes the model, so the app, tests and a real provider use the same code.triageis structured output with theTicketschema from lesson 7.answerruns the agent and reports pending approvals, with the messages needed to continue after a decision, as in lesson 9.refundLimitis a setting, not a prompt instruction, so no wording can change it.
import { createDesk } from "./desk.ts";
import { shopModel } from "./shop-model.ts";
const desk = createDesk(shopModel);
for (const ticket of ["I was charged twice", "Where is A-1001?", "Refund 30 euros on A-1001", "Refund 90 euros on A-1002"]) {
const triage = await desk.triage(ticket);
const reply = await desk.answer([{ role: "user", content: ticket }]);
const waiting = reply.approvals.map((a) => `${a.toolCall.toolName} ${JSON.stringify(a.toolCall.input)}`);
console.log(`${ticket}\n ${triage.category}/${triage.priority} | ${reply.text || "waiting for approval: " + waiting}`);
}npx tsx app.tsEvery ticket was triaged. The status question used the lookup tool, the 30 euro refund ran, and the 90 euro refund is waiting, with the tool call a person would review.
Hover or tap a piece to see what it is and which lesson built it.
Send a ticket
Testing the approval rule
import { test } from "node:test";
import assert from "node:assert/strict";
import { createDesk } from "./desk.ts";
import { shopModel } from "./shop-model.ts";
test("small refunds run without approval", async () => {
const reply = await createDesk(shopModel).answer([{ role: "user", content: "Refund 30 euros on A-1001" }]);
assert.equal(reply.approvals.length, 0);
assert.equal(reply.text, "Done: refunded 30 euros on A-1001.");
});
test("large refunds wait for a person", async () => {
const reply = await createDesk(shopModel).answer([{ role: "user", content: "Refund 90 euros on A-1002" }]);
assert.equal(reply.approvals.length, 1);
assert.equal(reply.text, "");
});
test("the refund limit is a setting", async () => {
const reply = await createDesk(shopModel, 100).answer([{ role: "user", content: "Refund 90 euros on A-1002" }]);
assert.equal(reply.approvals.length, 0);
});npx tsx --test desk.test.tsThe tests check the approval rule end to end: a refund under the limit runs, one over it waits and never runs, and the limit comes from code.
Running the desk on a hosted model
Pass openai("gpt-4.1-mini") to createDesk in app.ts and set OPENAI_API_KEY. Serve desk.answer from the chat route in lesson 14, and keep the stand-in in the tests.
What the SDK offers beyond the desk
| Topic | What it is for |
|---|---|
| Embeddings and reranking | embed, embedMany and rerank for retrieval. |
| MCP clients | Loading tools from MCP servers. |
| Generative UI | Rendering React components from tool results. |
| Message persistence | Saving and resuming chats with useChat. |
| Images, speech and transcription | generateImage, generateSpeech and transcribe. |
| Telemetry | OpenTelemetry spans for every call. |
| Other UI frameworks | Svelte, Vue and Angular bindings. |
Little by little, you're building something great.