Skip to content

23. Support desk

Build the support desk of Acme Coffee, a small online shop. Tickets come in, and Maitento sorts each one, looks up the order, reads the shop’s policies or the web, and drafts the reply. It asks a person before it refunds anything, and it remembers every customer. Every night it counts the day’s tickets in a sandbox. You watch all of it happen in a support console that updates as the agent works.

Your app is one small server, in TypeScript, Python or C#, with no AI code in it. The intelligence is the Maitento project, and every piece of it is something an earlier tutorial taught.

Acme Coffee's support console: an inbox of five tickets on the left. On the right, Sam Lee's ticket "Grinder handle snapped", sorted as damaged with high urgency, and a card that reads "The desk wants to refund £42.00 on order A-1001" with Approve refund and Reject buttons.

  • You’ll learn how the pieces from tutorials 3 to 21 fit together in one app: sorting, tools on your server, approvals, webhooks, documents, the web, memory, an app on a schedule and a sandbox. And how to deploy it.
  • New concepts none. Each piece links back to the tutorial that taught it.
  • Builds on 3 typed answers, 9 your code and secrets, 10 approvals, 11 webhooks, 13 documents, 14 the web, 15 file systems, 16 apps, 17 schedules, 20 memory and 21 sandboxes.
  • Time about 45 minutes.
  • Cost well under a cent per ticket on DeepSeek: a reply reads 10,000 to 35,000 tokens, most of them cached, and writes under 1,000. The brewing question also makes a metered web search and page read.
  • Needs a DeepSeek model key for the agents and an OpenAI model key, because the knowledge base and the memory search by meaning. Node.js 22.18, Python 3.10 or the .NET 10 SDK. A public address for your app: a free tunnel while you try it, then any container host.

Three boxes. The browser shows the support console and talks to your app with JSON; your app sends it every change as live events. Your app serves /api for the page, /maitento/webhook for Maitento, and /shop for the tools, and keeps its tickets in desk.json. Maitento holds triage-ticket, which your app runs for each ticket; draft-reply, the desk agent with a memory per customer, which your app starts, approves or rejects, and whose tools find-order and issue-refund call your app's /shop; and nightly-digest, an app that runs every night at 23 UTC, calls list-tickets on your app, and counts the tickets in a capsule. Maitento sends a webhook to your app whenever a reply or the digest changes.

  • Your app calls Maitento to sort a ticket, start its reply, approve or reject a refund, read what the desk remembers about a customer, and run the digest now.
  • Maitento calls your app for three tools (find-order, issue-refund, list-tickets), with a secret your app checks, and with a webhook every time a reply or the digest changes.
  • The browser only ever talks to your app: a small JSON API, and a stream of Server-Sent Events that carries each change as it happens. The API is described in web/API.md.

The page in web/ is plain HTML, CSS and JavaScript, and every server serves it as it is. The sample orders and tickets are in data/, the shop’s policies in knowledge/.

Maitento calls your app for the tools and the webhook, so it needs a public address. While you try it, start a free tunnel as in tutorial 9, and pick two secrets of your own:

cloudflared tunnel --url http://localhost:3000
# … Your quick Tunnel has been created! Visit it at: https://quiet-forest-1234.trycloudflare.com
export SHOP_API_KEY="pick-a-long-random-value"
export WEBHOOK_SECRET="another-long-random-value"
export WEBHOOK_URL="https://quiet-forest-1234.trycloudflare.com/maitento/webhook"

Put the tunnel’s address in the three shop-* connectors, in place of https://YOUR-SERVER.example.com:

maitento/connectors/open-api/shop-order-get.mtr
// The shop API your support desk app serves under /shop. Replace the host with your app's public
// URL in all three shop-* connectors (see tutorial 9 for a free tunnel).
method = "GET"
url = "https://YOUR-SERVER.example.com/shop/orders/{orderId}"
description = "Looks up one order by its id"
auth = "Header"
parameters.path = [
{
name = "orderId"
description = "The order id, for example A-1001"
}
]

Done when none of the connectors in maitento/connectors/open-api/ say YOUR-SERVER any more.

The setup script does, in order, what tutorials 9 to 21 did one at a time:

  1. stores SHOP_API_KEY as the shop-api-key secret, which the tools send to your app (tutorial 9);
  2. creates the support-desk file system and puts the digest’s script in it (tutorials 15 and 21);
  3. uploads the policies into the shop-policies knowledge base, and makes a temporary one for web pages (tutorials 13 and 14);
  4. deploys the project;
  5. registers your webhook for interaction runs and app runs (tutorial 11);
  6. schedules the nightly digest at 23:55 UTC (tutorial 17), and waits for the policies to be searchable.
import { readdir, readFile } from "node:fs/promises";
import { Maitento, type CreateAutomatedActionRequest } from "@maitento/sdk";
// The key your app's API checks, and where Maitento sends run updates: your app's public URL + /maitento/webhook.
const apiKey = process.env.SHOP_API_KEY;
const webhookUrl = process.env.WEBHOOK_URL;
const webhookSecret = process.env.WEBHOOK_SECRET;
if (!apiKey || !webhookUrl || !webhookSecret) {
throw new Error("Set SHOP_API_KEY, WEBHOOK_URL and WEBHOOK_SECRET first.");
}
const mt = new Maitento();
const namespace = process.env.MAITENTO_NAMESPACE ?? "tutorials";
// 1. The key the tools send to your app, kept as a secret (tutorial 9).
const secret = await mt.secrets.findByName("shop-api-key");
if (secret) await mt.secrets.update(secret.id, { name: "shop-api-key", value: apiKey });
else await mt.secrets.create({ name: "shop-api-key", value: apiKey });
// 2. The desk's file system, with the report script in it. The capsule mounts it, so it must exist
// before the deploy (tutorials 15 and 21).
if (!(await mt.namespaces.find(namespace))) await mt.namespaces.create({ name: namespace });
if (!(await mt.vfs.findByName("support-desk"))) await mt.vfs.create({ name: "support-desk" });
const script = await readFile("../data/count_tickets.py", "utf8");
await mt.vfs.open("support-desk").writeText("/count_tickets.py", script);
// 3. The shop's policies in a knowledge base (tutorial 13), and a temporary one for web pages (tutorial 14).
const partitions = await mt.partitions.list();
const policies = partitions.find((p) => p.name === "shop-policies" && !p.isTemporary)
?? (await mt.partitions.create({ name: "shop-policies" }));
for (const file of await readdir("../knowledge")) await mt.files.upload(policies.id, `../knowledge/${file}`);
if (!partitions.some((p) => p.name === "web-downloads" && p.isTemporary)) {
await mt.partitions.create({ name: "web-downloads", isTemporary: true, fileLifetimeHours: 24 });
}
// 4. The agents, tools, app and capsule.
await mt.deploy("../maitento");
// 5. Maitento calls your app whenever an interaction run (a reply) or an app run (the digest) changes (tutorial 11).
const hooks = await mt.webhooks.list();
for (const type of ["InteractionProcess", "AppProcess"] as const) {
if (!hooks.some((hook) => hook.url === webhookUrl && hook.type === type)) {
await mt.webhooks.create({ type, url: webhookUrl, secret: webhookSecret });
}
}
// 6. The nightly digest runs at 23:55 UTC (tutorial 17). An automation runs the app version it names,
// so running setup again points it at the version just deployed.
const app = await mt.apps.getByName("nightly-digest");
const latest = Math.max(...app.versions.map((v) => v.version));
const digest: CreateAutomatedActionRequest = {
name: "nightly-digest",
description: "Counts the day's support tickets",
actions: [{
name: "write-digest",
config: {
$type: "app",
version: { id: app.id, version: latest },
inputs: [{ name: "namespaceName", value: namespace }],
},
}],
};
const existing = await mt.automations.findByName("nightly-digest");
const automation = existing ? await mt.automations.update(existing.id, digest) : await mt.automations.create(digest);
if (!automation.schedules.some((s) => s.name === "every-night")) {
await mt.automations.addSchedule(automation.id, { name: "every-night", minute: "55", hour: "23" });
}
// The policies take a few seconds to index. Wait until the agent can search them.
for (;;) {
const files = [];
for await (const file of mt.partitions.files(policies.id)) files.push(file);
if (files.length > 0 && files.every((f) => f.isVectorGenerated)) break;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
console.log("The support desk is set up. Start the app with: node server.ts");
cd examples/23-support-desk/typescript
npm install
node setup.ts

Done when it prints The support desk is set up. It takes a minute or so: most of it is the policies being indexed.

Start the server with the same environment variables, and open http://localhost:3000:

node server.ts

The inbox is empty. Try a scenario files realistic tickets in one click, and New ticket lets you write your own as a customer. Reset demo at the bottom of the menu deletes the tickets and puts the orders back.

The scenario menu open over the empty inbox: A broken grinder, Sam writes again, A late parcel, Brewing advice and Stale beans, each with the customer's name and what it shows, and Reset demo at the bottom.

Done when the top bar says Live: the page is connected to your server’s event stream.

Choose A broken grinder. Sam Lee writes that the hand grinder from order A-1001 snapped. Within a few seconds the ticket is sorted, and soon after it waits for you, as in the picture at the top of this page.

Sorting. Triage is a one-shot interaction with a response schema, as in tutorial 3, so the app reads its answer straight into the ticket:

maitento/json/ticket-triage.json
{
"type": "object",
"properties": {
"category": { "type": "string", "enum": ["damaged", "delivery", "refund", "product", "other"] },
"urgency": { "type": "string", "enum": ["low", "normal", "high"] },
"orderId": { "type": ["string", "null"] },
"summary": { "type": "string", "description": "The ticket in one short sentence" }
},
"required": ["category", "urgency", "orderId", "summary"],
"additionalProperties": false
}

Starting the reply. Triage takes a few seconds, so the app waits for it. Drafting the reply can take a minute and may stop for an approval, so the app only starts it, and the webhook says how it goes (tutorial 11). The server answers the browser at once and does this work afterwards:

app.post("/api/tickets", (req, res) => {
const { customer, email, subject, message } = req.body as Record<string, string | undefined>;
if (!customer?.trim() || !email?.includes("@") || !message?.trim()) {
res.status(400).json({ error: "A ticket needs a customer, an email address and a message." });
return;
}
const ticket: Ticket = {
id: `T-${1001 + desk.tickets.length}`, customer: customer.trim(), email: email.trim(), subject: subject?.trim() ?? "",
message: message.trim(), created: new Date().toISOString(), status: "new", triage: null, runId: null,
loomId: null, updated: null, approval: null, decision: null, steps: [], reply: null, error: null,
};
desk.tickets.unshift(ticket);
changed(ticket);
res.status(201).json(ticket);
void work(ticket); // the browser hears how it goes over /api/events
});
async function work(ticket: Ticket) {
try {
// Triage takes a few seconds and answers in JSON, so wait for it (tutorial 3).
const triage = await mt.interactions.run("triage-ticket", { inputs: { message: ticket.message } });
ticket.triage = JSON.parse(triage.output!) as NonNullable<Ticket["triage"]>;
ticket.status = "triaged";
changed(ticket);
// The reply can take a minute, and may wait for a person to approve a refund.
// Start it and move on: the webhook says when it changes (tutorial 11).
const { category, urgency, orderId } = ticket.triage;
const { policies, webPages } = await findKnowledgeBases();
const run = await mt.interactions.start("draft-reply", {
inputs: { customer: ticket.customer, email: ticket.email, message: ticket.message, category, urgency, orderId,
policiesId: policies },
webDownloadPartitionId: webPages, // where web pages the agent reads are kept (tutorial 14)
});
ticket.runId = run.id;
ticket.loomId = run.looms[0]?.loom ?? null; // this customer's memory (tutorial 20)
ticket.status = "drafting";
changed(ticket);
} catch (error) {
ticket.status = "failed";
ticket.error = errorText(error);
changed(ticket);
}
}

The run that start returns already names the loom it woke up with: run.looms, the customer’s memory. The app keeps its id to show what the desk remembers (step 6).

The desk agent is told how to use each tool, and to remember the customer before it finishes:

maitento/prompts/desk-agent.md
You are the support desk of Acme Coffee, a small online shop that sells coffee beans and brewing kit.
- Search the shop's policies with search-policies before you promise anything, and follow what they say.
- Look orders up with find-order, by an order id the customer gave you or you remember for them, never by guessing
one. Never guess what an order holds, what it cost or where it is.
- When the policies say a refund is due, make it with issue-refund. A person approves every refund: tell the customer
it is done only if the tool call succeeded. If the person rejects it, don't ask for another refund: do what their
reason says, and tell the customer.
- For coffee questions the policies don't cover, such as brewing advice, search the web and keep to what you find.
- You remember customers, so treat one who has written before as someone you know. Before you finish, use LoomWeave
to keep what will matter next time: what they bought (the order id and each item), what went wrong, what you did
about it, and what they told you about themselves. Never weave payment details.
Your answer is the email to send to the customer, and nothing else: warm, short, and signed "Acme Coffee support".

Tools on your server. find-order, issue-refund and list-tickets call your app, with the shop-api-key secret in the X-Api-Key header, as in tutorial 9. A refund needs a person first, as in tutorial 10:

maitento/external-operations/open-api/issue-refund.mtr
connector = @ref("shop-order-refund")
auth = {
header = "X-Api-Key"
secret = @ref("shop-api-key")
}
guidance = "Refunds an order. Refund only what the policies allow, and never more than was paid for the item."
// A person approves every refund in the support desk app before Maitento calls it.
requiresApproval = true

Your app checks the key on everything under /shop, then looks orders up, refunds them, or lists a day’s tickets:

app.use("/shop", (req, res, next) => {
if (!constantTimeEqual(req.get("X-Api-Key") ?? "", apiKey)) {
res.status(401).json({ error: "Unauthorised" });
return;
}
next();
});
app.get("/shop/orders/:orderId", (req, res) => {
const order = desk.orders.find((o) => o.id === req.params.orderId);
if (order) res.json(order);
else res.status(404).json({ error: `There is no order ${req.params.orderId}` });
});
app.post("/shop/orders/:orderId/refund", (req, res) => {
const order = desk.orders.find((o) => o.id === req.params.orderId);
if (!order) {
res.status(404).json({ error: `There is no order ${req.params.orderId}` });
return;
}
const { amount, reason } = req.body as { amount: number; reason: string };
const refundable = order.items.reduce((sum, item) => sum + item.price, 0) - order.refunded;
if (!(amount > 0 && amount <= refundable)) {
res.status(400).json({ error: `You can refund between 0.01 and ${refundable.toFixed(2)} ${order.currency}` });
return;
}
order.refunded += amount;
save();
console.log(`Refunded ${amount} ${order.currency} on ${order.id}: ${reason}`);
res.json({ orderId: order.id, refunded: amount, totalRefunded: order.refunded, currency: order.currency });
});
// The nightly-digest app asks for one day's tickets.
app.get("/shop/tickets/:date", (req, res) => {
const day = desk.tickets.filter((t) => t.created.startsWith(req.params.date));
res.json({
date: req.params.date,
tickets: day.map((t) => ({ id: t.id, customer: t.customer, category: t.triage?.category ?? null,
urgency: t.triage?.urgency ?? null, status: t.status })),
});
});

Maitento says what changed. Every time the run changes, Maitento sends its whole state to your webhook, as in tutorial 11. The SDK’s verifyWebhook checks the delivery’s signature against the raw body (which is why the app keeps that one route’s body raw), and parseWebhook reads the run. Deliveries can repeat and arrive out of order, so the app keeps only the newest state of each run:

app.put("/maitento/webhook", async (req, res) => {
const body = req.body as Buffer; // the raw body, which the signature covers
if (!(await verifyWebhook(req.headers, body, webhookSecret))) {
res.sendStatus(401);
return;
}
const delivery = parseWebhook(req.headers, body);
res.sendStatus(204); // answer at once, and do the work after
try {
if (delivery.type === "InteractionProcess") await runChanged(delivery.entity);
if (delivery.type === "AppProcess") digestChanged(delivery.entity);
} catch (error) {
console.error(`Webhook for ${delivery.id}: ${errorText(error)}`);
}
});
async function runChanged(run: InteractionProcess) {
// Webhooks come for every run in the tenant: keep only this desk's. Deliveries can repeat and arrive out of
// order, so keep only the newest state of each run.
const ticket = desk.tickets.find((t) => t.runId === run.id);
const updated = run.dateUpdated.toISOString();
const isOld = () => ticket?.updated != null && updated <= ticket.updated;
if (!ticket || isOld()) return;
const steps = await toolCalls(run, ticket.steps);
if (isOld()) return; // a newer delivery got here while the tool results were being read
const approval = run.status === "PendingApproval" ? run.pendingApproval : null;
ticket.updated = updated;
ticket.steps = steps;
ticket.approval = approval && { id: approval.id, tool: approval.functionName, arguments: approval.argumentsJson };
ticket.reply = run.output;
ticket.error = run.failureDetails;
if (run.status === "PendingApproval") ticket.status = "approval";
else if (run.status === "Finished") ticket.status = ticket.decision?.approved === false ? "rejected" : "replied";
else if (run.status === "Error" || run.status === "Killed") ticket.status = "failed";
else ticket.status = "drafting";
changed(ticket);
}

What the desk did. The run’s state holds every tool call the agent made and its result. The page turns them into the What the desk did list: the order it looked up, the policy passages it read, the web pages it opened, the refund it asked for, what it chose to remember. A long result is kept outside the run, so the app fetches it once:

async function toolCalls(run: InteractionProcess, known: Step[]): Promise<Step[]> {
return Promise.all(run.context.toolCall.map(async (call) => {
const result = run.context.toolResult.find((r) => r.toolCallId === call.toolCallId);
let output = result?.result ?? known.find((s) => s.id === call.toolCallId)?.output ?? null;
if (result && output === null) output = (await mt.interactionRuns.toolResult(run.id, result.sequence)).result;
return {
id: call.toolCallId, tool: call.functionName, input: call.argumentsJson, output,
error: result?.isError ?? false, rejection: result?.rejectionReason ?? null,
};
}));
}

You decide. The card shows what the agent wants to do. Approve refund lets Maitento call your app; Reject… asks why, and the agent is told your reason:

app.post("/api/tickets/:id/:decision", async (req, res) => {
const ticket = desk.tickets.find((t) => t.id === req.params.id);
const approval = ticket?.approval;
const approve = req.params.decision === "approve";
if (!ticket?.runId || !approval || !["approve", "reject"].includes(req.params.decision)) {
res.status(409).json({ error: "This ticket isn't waiting for an approval." });
return;
}
const reason = (req.body as { reason?: string }).reason?.trim() || "The support team said no.";
ticket.approval = null; // a second click finds nothing to decide
ticket.decision = { approved: approve, reason: approve ? null : reason };
ticket.status = "drafting";
changed(ticket);
try {
if (approve) await mt.interactionRuns.approve(ticket.runId, { approvalId: approval.id });
else await mt.interactionRuns.reject(ticket.runId, { approvalId: approval.id, reason });
res.json(ticket);
} catch (error) {
Object.assign(ticket, { approval, decision: null, status: "approval" });
changed(ticket);
res.status(502).json({ error: errorText(error) });
}
});

Click Approve refund. Your server prints Refunded 42 GBP on A-1001: Hand grinder handle snapped off on first use…, and the reply appears:

Dear Sam,
Thank you for getting in touch, and I am sorry to hear that the handle on your hand grinder snapped the first time you
used it. That is not the experience we want you to have.
I have refunded the full price of the grinder, £42.00, back to your original payment method. The money should appear
within 5 working days. There is no need to send the grinder back to us, though a photo would be helpful if you have
one to hand. …

Done when the ticket says Replied, and What the desk did lists the order, the returns policy, the £42 refund and a memory. The agent refunded the grinder’s price, not the whole order: the policy says so.

Choose A late parcel and Brewing advice.

Ana’s parcel was sent six working days ago. The agent looks the order up, searches the policies, and tells her it is late, that the shop is chasing the carrier, and what happens after ten working days. The search tool is tutorial 13’s: the knowledge base’s id comes in with each run and is bound straight into the call, so the model only writes the query and never sees, or mistypes, an id:

maitento/interactions/one-shot/draft-reply.mtr
agent = {
name = "Desk"
agent = @ref("desk-agent")
}
task = @prompt("draft-reply-task")
inputs.prompts = [
{
name = "customer"
description = "The customer's name"
required = true
},
{
name = "email"
description = "The customer's email address: whose memory the agent wakes up with"
required = true
},
{
name = "message"
description = "What the customer wrote"
required = true
},
{
name = "category"
required = true
},
{
name = "urgency"
required = true
},
{
name = "orderId"
required = false
}
]
functions.openApi = [
{
name = "find-order"
ref = @ref("find-order")
},
{
name = "issue-refund"
ref = @ref("issue-refund")
},
{
name = "search-policies"
ref = @ref("search-policies")
}
]
// The knowledge base id comes in with each run and goes straight into the search (tutorial 13).
inputs.functions.binding = [
{
function = @ref("search-policies")
name = "policiesId"
target = "partitionId"
description = "The knowledge base that holds the shop's policies"
required = true
type = "request"
}
]
// One memory per customer, chosen by their email address (tutorial 20).
loom = {
enabled = true
scope = "Input"
scopeInput = "email"
subjectInputs = [ "customer" ]
recall = "Suggest"
weave = "Manual"
}
duration.warn = "00:03:00"
duration.max = "00:05:00"

Priya asks how to brew a light roast in her AeroPress. The policies don’t cover that, so the agent searches the web and reads a brewing guide, with the built-in web tools from tutorial 14 (allowWebSearch and allowWebFetch on the agent). The pages it reads are kept for a day in the temporary web-downloads knowledge base.

Priya Shah's ticket "AeroPress settings", replied: a grind, water, dose and method for a light roast. Under What the desk did: recalled what it knew about Priya (nothing yet), searched the policies, searched the web for AeroPress light roast settings with four results, read a guide on moustachecoffeeclub.com, and remembered that Priya bought an AeroPress Go and brews light roasts. On the right, that memory, read back from Maitento.

Done when Ana’s What the desk did shows the delivery policy’s late-parcels passage, and Priya’s shows a web search and a page it read.

Now choose Sam writes again. Sam asks which beans he bought last time. He doesn’t give an order id, or name the beans:

Hello again! I'd like to order more of the coffee I bought from you last time, but I've lost the email. Which beans
were they?

The agent knows. It wakes up with Sam’s memory, finds order A-1001 in it, looks the order up to be sure, and answers:

Hello Sam,
Lovely to hear from you again. The coffee on your last order (A-1001) was our Ethiopia Yirgacheffe 250g at £11.50. …

The memory is scoped to the email input, as Wren’s is to userName in tutorial 20: one loom per customer, so Sam’s history never turns up in Ana’s reply. weave = "Manual" lets the agent decide what is worth keeping, and its instructions say what: the order, what went wrong, what it did.

What the desk remembers about Sam is read from Maitento, not from the run: the threads of Sam’s loom, through the SDK’s looms API:

app.get("/api/tickets/:id/memory", async (req, res) => {
const loomId = desk.tickets.find((t) => t.id === req.params.id)?.loomId;
try {
const threads = loomId ? await mt.looms.threads(loomId, { sort: "Newest", limit: 20 }) : [];
res.json({ threads: threads.map((t) => ({ content: t.content, kind: t.kind, created: t.dateCreated })) });
} catch (error) {
res.status(502).json({ error: errorText(error) });
}
});

Sam's second ticket "Reordering" in the dark theme: the reply names the Ethiopia Yirgacheffe 250g from order A-1001. What the desk did: looked up order A-1001, remembered something for next time. What the desk remembers about Sam: three threads, about the grinder that snapped and was refunded, the beans he bought, and that he values faulty gear being put right quickly.

Done when the reply names the Yirgacheffe, though Sam never did. Send Sam writes again before A broken grinder and it can’t: there is nothing to remember yet.

Choose Stale beans. Tom’s Kenya AA is more than four weeks past its roast date, so the policy says refund it, and the agent asks to. Click Reject… and say why, for example We'd rather send Tom a fresh bag than refund it. The agent reads your reason and writes the reply around it: a fresh bag, no refund.

Tom Okafor's ticket on a phone, in the dark theme: status Rejected, the triage, Tom's message, a note "You rejected the refund", and the reply below it.

Done when the ticket says Rejected, and the reply offers a fresh bag instead of money.

Every night at 23:55 UTC an automation runs the nightly-digest app (tutorials 16 and 17). It calls your app through list-tickets (Maitento adds the secret, as it does for the agent), saves the day’s tickets in the support-desk file system (tutorial 15), and starts a capsule (tutorial 21) that counts them:

maitento/apps/nightly-digest/nightly-digest.mtc
// The nightly digest. It fetches the day's tickets from your app, keeps them in the desk's file
// system, and runs a capsule that turns them into a report.
// main's parameter is the app's input: the namespace the file system and the capsule live in.
string main(string namespaceName) {
string day = DateTimeToString(DateGetUtc(), "yyyy-MM-dd");
// Call your app through the list-tickets tool: Maitento adds the secret, as it does for the agent's tools.
jsonObject path = JsonObjectCreate();
path = JsonObjectSetString(path, "date", day);
string response = ExtOpInvoke("list-tickets", "", "", path, "");
jsonArray tickets = JsonObjectGet(StringToJsonObject(response), "tickets");
PrintLine("Tickets on " + day + ": " + JsonArrayCount(tickets));
// Save them where the capsule can read them.
VfsWriteText("support-desk", namespaceName, "/tickets/" + day + ".json", response);
// The capsule mounts the file system, counts the tickets and writes the report beside them.
guid capsuleId = CapsuleStart("ticket-report", namespaceName, "[]", "[]", "[]");
jsonObject capsule = CapsuleAwait(capsuleId);
if (JsonObjectGet(capsule, "status") != "Finished") {
throw("The ticket-report capsule ended " + JsonObjectGet(capsule, "status"));
}
string report = VfsReadText("support-desk", namespaceName, "/reports/latest.md");
AppStateSetOutputString("report", report);
return("Digest written for " + day);
}

The capsule mounts the file system and runs data/count_tickets.py, which writes the report twice: latest.md for people, and latest.json for the console’s charts.

Run digest now on the Digest tab triggers the automation instead of waiting for tonight. The app run’s webhook tells your app when it has finished, and the page reads the report:

let digest = { running: false, error: null as string | null };
const readReport = async (path: string) => ((await deskFiles.exists(path)) ? deskFiles.readText(path) : null);
app.get("/api/digest", async (_req, res) => {
try {
const [report, counts] = await Promise.all([readReport("/reports/latest.md"), readReport("/reports/latest.json")]);
res.json({ ...digest, report, counts: counts ? JSON.parse(counts) : null });
} catch (error) {
res.status(502).json({ error: errorText(error) });
}
});
app.post("/api/digest", async (_req, res) => {
try {
const automation = await mt.automations.getByName("nightly-digest");
await mt.automations.trigger(automation.id);
digest = { running: true, error: null };
send("digest", digest);
res.status(202).json(digest);
} catch (error) {
res.status(502).json({ error: errorText(error) });
}
});
function digestChanged(run: AppProcess) {
if (run.triggeredByAutomatedActionName !== "nightly-digest") return;
const ended = ["Finished", "Error", "Killed"].includes(run.status);
digest = { running: !ended, error: ended && run.status !== "Finished" ? run.failureDetails ?? run.status : null };
send("digest", digest);
}

The Digest tab in the dark theme, for Monday 26 October 2026: 5 tickets, 4 customers, 4 replied, 0 waiting for approval; bar charts by category, urgency and status; the four customers; and the report as the sandbox wrote it, collapsed.

To run it from a terminal instead, and print what the app and the capsule wrote:

import { Maitento } from "@maitento/sdk";
// Run tonight's digest now, instead of waiting for 23:55 UTC.
const mt = new Maitento();
const automation = await mt.automations.getByName("nightly-digest");
const { processId } = await mt.automations.trigger(automation.id);
console.log(`Started the nightly-digest app: ${processId}`);
// Follow the app run until it ends.
const finished = new Set(["Finished", "Error", "Killed"]);
let run = await mt.appRuns.get(processId);
while (!finished.has(run.status)) {
await new Promise((resolve) => setTimeout(resolve, 2000));
run = await mt.appRuns.get(processId);
}
for (const line of run.consoleOutputs) console.log(line.text.trimEnd());
if (run.status !== "Finished") throw new Error(`The digest ended ${run.status}: ${run.failureDetails}`);
console.log(`\n${run.returnValue}\n`);
console.log(await mt.vfs.open("support-desk").readText("/reports/latest.md"));

Done when the Digest tab shows today’s counts, five tickets if you sent the five scenarios.

Nothing on the page refreshes. The browser keeps one request open to /api/events, and the server writes an event down it whenever a ticket or the digest changes: when it is filed, sorted, drafted, waiting, decided or answered. That’s Server-Sent Events, which every browser reads with EventSource:

const listeners = new Set<Response>();
const send = (event: string, data: unknown) => {
for (const listener of listeners) listener.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
const changed = (ticket: Ticket) => {
save();
send("ticket", ticket);
};
app.get("/api/events", (req, res) => {
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
res.write(": connected\n\n");
listeners.add(res);
req.on("close", () => listeners.delete(res));
});
setInterval(() => send("ping", {}), 15_000); // keeps proxies from closing a quiet stream

So a change travels Maitento → webhook → your app → event → page, and the page never asks Maitento anything itself. Your API key stays on your server.

The inbox on a phone, in the light theme: five tickets, each with the customer, the subject, category and urgency chips, and a status.

Done when you file a ticket in one browser window and watch it move through New, Triaged, Drafting and Replied in another.

The desk runs anywhere that runs a container and gives it a public HTTPS address: Maitento has to reach it for the tools and the webhook. Build the image from the example’s folder, so the page and the data go in with the server:

typescript/Dockerfile
# The TypeScript support desk in a container. Build it from the example's folder, so web/ and data/ come along:
#
# docker build -f typescript/Dockerfile -t acme-support-desk .
# docker run -p 3000:3000 -v desk-state:/app/state \
# -e MAITENTO_API_KEY -e MAITENTO_NAMESPACE -e SHOP_API_KEY -e WEBHOOK_SECRET acme-support-desk
#
# The image also holds setup.ts and the Maitento project, so `docker run … acme-support-desk node setup.ts` sets up
# Maitento from the same image (it needs WEBHOOK_URL as well).
FROM node:22-slim
WORKDIR /app/typescript
COPY typescript/package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY typescript/*.ts ./
COPY web ../web
COPY data ../data
COPY knowledge ../knowledge
COPY maitento ../maitento
# The tickets and orders are kept in a volume, so they outlive the container.
RUN mkdir -p /app/state && chown node /app/state
VOLUME /app/state
ENV NODE_ENV=production PORT=3000 DESK_FILE=/app/state/desk.json
USER node
EXPOSE 3000
CMD ["node", "server.ts"]
  1. Build it: docker build -f typescript/Dockerfile -t acme-support-desk . (python/Dockerfile builds the Python server the same way).
  2. Run it on your host with MAITENTO_API_KEY, MAITENTO_NAMESPACE, SHOP_API_KEY and WEBHOOK_SECRET set from the host’s secret store, never baked into the image, and a volume at /app/state so the tickets outlive a restart.
  3. Point Maitento at it: put its address in the three shop-* connectors, and run setup once more with WEBHOOK_URL=https://<its address>/maitento/webhook. The image holds setup too: docker run --rm -e WEBHOOK_URL -e … acme-support-desk node setup.ts.
  4. Delete the tunnel’s webhooks, which still point at your laptop: mt.webhooks.list(), then mt.webhooks.delete(id) for each.

Done when the page loads from the host’s address, and a scenario there gets a reply.

On GitHub: examples/23-support-desk · TypeScript · Python · C#

All the files in this project (34)
model = @ref("deepseek.deepseek-v4-flash")
prompt = @prompt("desk-agent")
summary = "Answers Acme Coffee's customers"
// Maitento's built-in web tools, for coffee questions the shop's policies don't cover (tutorial 14).
allowWebSearch = true
allowWebFetch = true

Not shown here, on GitHub: web/app.js (43 KB), web/mascot.png (not text), web/styles.css (28 KB).

  • none-for-provider on a ticket: the tenant has no model key for the agents’ provider. Add a DeepSeek key, or change model in the two agent files to a provider you have a key for.
  • Setup never finishes after the deploy: it is waiting for the policies to be indexed, which needs an OpenAI model key for the embeddings.
  • The banner says Maitento isn’t ready: MAITENTO_API_KEY or MAITENTO_NAMESPACE is missing or wrong in the server’s environment, or setup hasn’t run in that namespace.
  • A ticket stays at Drafting: the webhook doesn’t reach your app. Check that WEBHOOK_URL is the tunnel’s current address plus /maitento/webhook, that the server has the same WEBHOOK_SECRET setup used, and that the tunnel is still running. A quick tunnel’s address changes each time it starts.
  • What the desk did shows 401 on find-order: the server’s SHOP_API_KEY isn’t the value setup stored. Run setup again with the server’s value.
  • A tool says it couldn’t reach your app: a connector still has YOUR-SERVER.example.com, or an old tunnel address. Fix it and run setup again: it redeploys.
  • The digest keeps spinning: your app never heard that the app run ended. Setup registers a webhook for app runs as well as interaction runs; run it again.
  • Sam isn’t remembered: send A broken grinder first and wait for its reply. The agent weaves its memory as it finishes, and the second ticket must use the same email address.
  • The agent asks for a second refund after you rejected one: models sometimes do, even though their instructions say not to. Reject it again; nothing is refunded without you.

You built a support desk out of earlier tutorials, and each piece does one job:

PieceWhat it does hereTutorial
A typed answersorts each ticket into JSON the app can read3
Tools and a secretthe agent reads and refunds orders on your server9
An approvala person allows every refund before it happens10
Webhooksyour app hears about every change, without waiting11
A knowledge basethe agent quotes the shop’s own policies13
Web toolsit answers what the policies don’t cover14
A file system, an app, a schedulethe nightly digest gathers the day’s tickets15, 16, 17
Memoryeach customer is remembered, and only their own20
A sandboxthe digest’s numbers are counted in a throwaway machine21
  • Your app has no AI code in it. It starts runs, reads their state, and makes the decisions only a person may make.
  • Start what’s slow, wait for what’s quick. Triage takes seconds, so the app waits; a reply takes a minute and may pause for a person, so the app starts it and lets the webhook report back.
  • Your systems are the record. The agent remembers refunding Sam, but the order is what says he was refunded. After Reset demo the order shows nothing refunded while the memory still says it was, and a person approving refunds is what catches the difference.

The policies cover subscriptions (skipping, pausing and changing a delivery), but triage files every subscription question under other. Teach triage a subscription category, redeploy, and send this as a new ticket:

Can I skip next week's subscription delivery? I'm away until the 9th.

Done when the ticket shows a subscription chip, the category filter offers Subscription, and the reply quotes the 09:00 cut-off from the policies.

Solution

Two files change: the schema gets the new value, and the triage task says when to use it. Redeploy by running setup again.

solution/maitento/json/ticket-triage.json
{
"type": "object",
"properties": {
"category": { "type": "string", "enum": ["damaged", "delivery", "refund", "subscription", "product", "other"] },
"urgency": { "type": "string", "enum": ["low", "normal", "high"] },
"orderId": { "type": ["string", "null"] },
"summary": { "type": "string", "description": "The ticket in one short sentence" }
},
"required": ["category", "urgency", "orderId", "summary"],
"additionalProperties": false
}
solution/maitento/prompts/triage-task.md
Sort this support ticket.
- category: `damaged` (arrived broken or faulty), `delivery` (late, lost or wrong), `refund` (wants money back for
another reason), `subscription` (skipping, pausing or changing a subscription delivery), `product` (a question
about coffee or kit), or `other`.
- urgency: `high` if the customer is out of pocket or very upset, `low` for a general question, otherwise `normal`.
- orderId: the order id the ticket mentions, such as A-1001, or null.
The ticket:
%%message%%

The app, the page and the digest need no change: a category is just a value to them.

  • Let it draft fixes. Add a website category for tickets about the shop’s site. When triage picks it, start a code pipeline on your site’s repository with the ticket as its request.
  • Route by urgency. A high ticket could start a different interaction, or page someone, before the reply is drafted.
  • Send a replacement for real. Give the agent a send-replacement tool that needs approval too, so a rejected refund can become a fresh bag the shop actually posts.
  • Put the console behind a login. This page lets anyone approve refunds. In a real desk, customers write to an inbox or a form, and only staff see the console.
  • An automation runs the app version it names. Setup points nightly-digest at the latest version each time it runs, so redeploying through setup keeps the schedule running your newest app.
  • Approvals happen in interactions. A tool that needs approval doesn’t run when an app calls it with ExtOpInvoke, so give apps tools that don’t, like list-tickets.
  • The C# server is a .NET 10 file-based web app (#:sdk Microsoft.NET.Sdk.Web). File-based apps are compiled ahead of time by default; #:property PublishAot=false turns that off, because the server reads and writes JSON by reflection.

More in Concepts.