Build a support assistant that looks up orders and cancels them only with a person's approval, using plain TypeScript methods as its tools.
An AI agent is a loop: the model reads the conversation, decides to call one of your functions (a tool), sees the result, and either calls another or answers. The hard parts are around the loop. You have to describe each tool to the model, check the arguments it invents, stream the answer to the browser, and stop before anything risky happens.
In Amoxi you write the tools as ordinary methods and the agent as a small class. The build generates the rest from your types. We will build a support assistant for a small outdoor shop.
Start from the agent template
npx create-amoxi-app support-demo --template agent
cd support-demo
npm run dev
Open http://localhost:8080/assistant. The template's assistant can already list and save notes. Try "Show my notes".
You do not need an API key. Without one, Amoxi uses a built-in offline model that picks tools by keyword and fills in their arguments from the message. It is not clever, but it lets you build, demo and test everything offline. To use a real model, add one line to a .env file in the project folder:
ANTHROPIC_API_KEY=your-key
OPENAI_API_KEY works too, as does any OpenAI-compatible server such as Ollama, through OPENAI_BASE_URL.
Tools are methods
Create src/orders/order-tools.ts. The orders are kept in memory so the example stays short; in a real app this class would call your database or order system.
import { Singleton } from 'amoxi';
import { Tool, ToolError } from 'amoxi/ai';
export interface Order {
id: string;
item: string;
status: 'processing' | 'shipped' | 'delivered' | 'cancelled';
eta?: string;
}
@Singleton()
export class OrderTools {
private readonly orders = new Map<string, Order>([
['A-1001', { id: 'A-1001', item: 'Trail backpack, 28 L', status: 'shipped', eta: '2026-10-22' }],
['A-1002', { id: 'A-1002', item: 'Camp mug, enamel', status: 'processing' }],
]);
/** Look up an order by its number and report its status. */
@Tool({ readOnly: true })
lookupOrder(input: {
/**
* The order number, e.g. A-1001
* @pattern ^A-\d{4}$
*/
orderId: string;
}): Order {
const order = this.orders.get(input.orderId);
if (!order) throw new ToolError(`There is no order ${input.orderId}.`);
return order;
}
}
That is a complete tool. From it, the build produces:
-
A name:
lookup_order. - A description: the method's JSDoc.
-
A JSON Schema for the input: from the parameter type, including the field comment and the
@patternrule, exactly like request validation in article 4. -
A validator: if the model sends
{ "orderId": "1001" }, the call never reaches your code. The error goes back to the model so it can correct itself.
readOnly: true records that the tool changes nothing. ToolError is for messages meant for the model and the person, like "There is no order A-9999." Any other error is reported only as "internal error" with a reference id in production, so database details never leak into a chat.
The agent is a class
Create src/orders/support-agent.ts:
import { Agent, type AgentRunContext } from 'amoxi/ai';
import { OrderTools } from './order-tools';
/** Answers questions about orders and cancels them on request. */
@Agent({
tools: [OrderTools],
examples: ['Where is order A-1001?', 'Cancel order A-1002'],
})
export class SupportAgent {
instructions(ctx: AgentRunContext): string {
return [
'You are the support assistant of a small outdoor shop. Be brief and friendly.',
'Always look orders up with the tools; never guess a status or a date.',
`Today is ${ctx.now.toDateString()}.`,
].join('\n');
}
}
instructions() is the system prompt, built for every run, so it can include the date or details about the user. The agent's name comes from the class name, support-agent, and its description from the JSDoc.
That is enough to talk to it. Every agent gets an HTTP API, so try it from a terminal (on Windows PowerShell, type curl.exe):
curl -N -X POST -H "content-type: application/json" -H "accept: text/event-stream" -d '{"input":"Where is order A-1001?"}' http://localhost:8080/agents/support-agent/runs
The answer streams back as Server-Sent Events. Shortened:
event: message.delta
data: {"text":"Let "}
event: tool.started
data: {"tool":"lookup_order","input":{"orderId":"A-1001"}}
event: tool.completed
data: {"tool":"lookup_order","output":{"id":"A-1001","item":"Trail backpack, 28 L","status":"shipped","eta":"2026-10-22"}}
event: message.delta
data: {"text":"Here's "}
Ask about A-9999 instead and the ToolError comes back to the model, which tells the customer: "That didn't work: There is no order A-9999."
Ask before acting
Looking things up is harmless. Cancelling is not, so it should wait for a person. Add a second tool to OrderTools:
/** Cancel an order that has not shipped yet. */
@Tool({ approval: true })
cancelOrder(input: {
/**
* The order number, e.g. A-1002
* @pattern ^A-\d{4}$
*/
orderId: string;
}): Order {
const order = this.orders.get(input.orderId);
if (!order) throw new ToolError(`There is no order ${input.orderId}.`);
if (order.status !== 'processing') throw new ToolError(`Order ${order.id} has already shipped.`);
order.status = 'cancelled';
return order;
}
With approval: true, when the model decides to cancel, the run pauses with the status awaiting_approval and sends an approval.required event naming the tool and its arguments. Nothing has run yet. It continues only when someone answers:
POST /agents/support-agent/runs/<run id>/approvals/<call id> {"approved": true}
The agent then cancels the order and reports "Status: cancelled". If the person declines, the model is told so and answers accordingly. Approvals are enforced on the server, so a crafted request or a confused model cannot skip them.
A chat in the browser
You could build the chat UI from those events yourself, but agentChat() already turns the conversation into signals (article 3). The template's src/islands/Assistant.tsx is a complete chat: messages, tool steps, Allow and Decline buttons. Copy it to src/islands/SupportChat.tsx, rename the function to SupportChat, and point it at the new agent:
const chat = agentChat('support-agent');
Then give it a page, src/pages/support-page.tsx:
import { Controller, Island, Page } from 'amoxi';
import SupportChat from '../islands/SupportChat';
import { Layout } from '../ui/layout';
@Controller()
export class SupportPage {
@Page('/support')
support() {
return (
<Layout title="Support">
<h1>Order support</h1>
<Island of={SupportChat} examples={['Where is order A-1001?', 'Cancel order A-1002']} />
</Layout>
);
}
}
Open http://localhost:8080/support, click "Cancel order A-1002", and the conversation shows an Allow button before anything happens.
Show a card, not a paragraph
Agents can show components as well as text. Mark an island as generative with a JSDoc tag, src/islands/OrderCard.tsx:
/** @generative Shows one order with its status */
export default function OrderCard(props: { id: string; item: string; status: string; eta?: string }) {
return (
<article class="order-card">
<h3>Order {props.id}</h3>
<p>{props.item}</p>
<p><strong>{props.status}</strong>{props.eta ? `, arriving ${props.eta}` : ''}</p>
</article>
);
}
Allow the agent to use it with ui: ['OrderCard'] in @Agent, and show it from the tool by adding a ctx: ToolContext parameter:
ctx.ui('OrderCard', { ...order });
Now "Where is order A-1001?" puts a real order card into the chat. The props are checked against the component's type, and the model also gets a show_order_card tool, so it can show the card on its own initiative.
Test it without a model
The offline model is random enough to be fun but not predictable enough for tests. For tests, script the model's turns exactly:
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { AmoxiApplication, Config } from 'amoxi';
import { AiServices, ScriptedChatModel } from 'amoxi/ai';
import { applicationModule } from '../src/generated/amoxi.generated';
test('the support agent looks up an order before answering', async () => {
const model = new ScriptedChatModel([
{ toolCalls: [{ name: 'lookup_order', input: { orderId: 'A-1001' } }] },
{ text: 'Your backpack arrives on October 22.' },
]);
const app = await AmoxiApplication.run(applicationModule, {
config: Config.of({ server: { port: 0 } }),
overrides: [['ChatModel', model]],
silent: true,
shutdownHook: false,
});
try {
const platform = AiServices.of(app.context).platform();
const started = await platform.startRun({ agent: 'support-agent', input: 'Where is my backpack? A-1001', owner: 'me' });
let run = await platform.getRun(started.id);
while (run && !['completed', 'failed'].includes(run.status)) {
await new Promise((r) => setTimeout(r, 20));
run = await platform.getRun(started.id);
}
assert.equal(run?.output, 'Your backpack arrives on October 22.');
assert.match(JSON.stringify(model.requests[1].messages.at(-1)), /shipped/);
} finally {
await app.close();
}
});
The scripted model calls the real tool, so the test checks your tool code, the wiring and the loop, in a fraction of a second and without network access. Run it with npm test.
Mistakes the build catches
As with dependency injection, agent wiring is checked at build time: a tool class that is not a bean, two tools with the same name, an unknown UI component, a tool without a description, or an agent without instructions() all stop amoxi build with the file and line.
Recap
-
@Toolturns a method into a tool. Its schema and validator come from the parameter type. -
@Agentis a class withinstructions()and a list of tool classes. - Every agent has a streaming HTTP API and works in the browser through
agentChat(). -
approval: truepauses the run until a person decides. -
@generativeislands let the agent show components. -
ScriptedChatModelmakes agent tests fast and exact.
Next
Runs in this article took a second. Article 6 is about runs that take minutes, servers that restart in the middle of one, and letting other AI agents call yours over MCP and A2A.
Amoxi is open source under the Apache License 2.0. Code: github.com/karawash-projects/amoxi. Documentation: karawash-projects.github.io/amoxi.
Top comments (0)