DEV Community

Ahmad Karawash
Ahmad Karawash

Posted on

Amoxi: Your first AI agent in TypeScript

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
Enter fullscreen mode Exit fullscreen mode
cd support-demo
Enter fullscreen mode Exit fullscreen mode
npm run dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 @pattern rule, 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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 "}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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();
  }
});
Enter fullscreen mode Exit fullscreen mode

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

  • @Tool turns a method into a tool. Its schema and validator come from the parameter type.
  • @Agent is a class with instructions() and a list of tool classes.
  • Every agent has a streaming HTTP API and works in the browser through agentChat().
  • approval: true pauses the run until a person decides.
  • @generative islands let the agent show components.
  • ScriptedChatModel makes 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)