# Build an AI agent in TypeScript

> An AI agent in Chloe is a TypeScript file listing its jobs, tools and channels. Here is one, built from the ground up, with a model asked only where it is needed.

An AI agent here is not one long prompt. It is a TypeScript file that says what
the agent is: its instructions, the jobs it runs, the tools it may hand a model
and the chats it answers on. Each job is an ordinary async function, and a
model is one kind of step inside it, used where a step needs judgement and
nowhere else.

## Install

Node 22.18 or newer. There is no build step: Node runs the TypeScript as it is.

```sh
npm install @chloejs/core
npx chloe setup
```

`setup` asks a name and a model, writes the files and runs a first job, so the
first thing you see is a finished run. [Start](https://chloejs.org/docs/start) goes through what it
wrote.

## The agent

One file, `agent.ts`, and it is the whole list. A job that is not on it does not
run. This is the shop the examples on this site come from:

```ts
// The one agent this repo ships, and the one its docs quote: a small shop's
// back office, with a job at each level of autonomy. Orders that are late (no
// model at all), the inbox sorted (one model step), buying stock (code decides,
// a model writes the line), a customer's problem looked into (one of each, in
// one job), a customer who went quiet (an agent step), a refund big enough that a
// person decides, and one job that is a prompt from end to end.
//
// `services/` stands in for the systems a shop already has. Point those at the real
// thing and nothing in `jobs/` changes.
//
// Copy this folder, rename it, and put your own name in chloe.config.ts.
import { defineAgent, prompt } from "@chloejs/core";

import api from "./channels/api.ts";
import telegram from "./channels/telegram.ts";
import web from "./channels/web.ts";
import whatsapp from "./channels/whatsapp.ts";
import bigRefunds from "./jobs/big-refunds.ts";
import orderIssues from "./jobs/order-issues.ts";
import howItWent from "./jobs/how-it-went.ts";
import restock from "./jobs/restock.ts";
import sortMessages from "./jobs/sort-messages.ts";
import stuckOrders from "./jobs/stuck-orders.ts";
import whyTheyLeft from "./jobs/why-they-left.ts";
import { orderStatus } from "./tools/orderStatus.ts";

export default defineAgent({
  id: "shop",
  label: "Shop",
  // Any model the gateway or the CLI can reach. A job can name a different one
  // for itself, which is the point of choosing here rather than in the runtime.
  model: "anthropic/claude-sonnet-5",
  description: "Watches the orders, the inbox, the shelves and the customers who stopped buying.",
  instructions: prompt("instructions.md"),
  tools: { orderStatus },
  jobs: [stuckOrders, sortMessages, restock, orderIssues, whyTheyLeft, bigRefunds, howItWent],
  channels: [telegram, whatsapp, api, web],
});
```

## A job, with a model in the middle

Code reads the inbox, one model step works out what each message is about and
answers in a shape checked against a zod schema, and code decides which desk
each one goes to. Free text never reaches the next line of code.

```ts
export default defineJob({
  id: "sort-messages",
  cron: every(15).minutes,
  timezone: "America/New_York",
  description: "Reads what customers wrote in and puts each one on the right desk.",
  model: "anthropic/claude-haiku-4.5",
  run: async (work) => {
    const waiting = await work.step("read what came in", () => unread());
    if (waiting.length === 0) return { sorted: 0, urgent: [], desks: {} as Record<string, number> };

    const read = await work.model("work out what each one is about", {
      instructions:
        "You sort a shop's inbox. Judge only what the customer wants, not what should be done about it. " +
        "Every message gets exactly one line back, with the id copied exactly.",
      output: Sorted,
      prompt: waiting.map((one) => `${one.id} (${one.at}): ${one.text}`).join("\n\n"),
    });

    // The rules, in code: where each one goes, and which are worth interrupting
    // somebody for. The model never decides either.
    const desks: Record<string, number> = {};
    for (const desk of new Set(read.sorted.map((one) => DESK[one.about]))) {
      const ids = read.sorted.filter((one) => DESK[one.about] === desk).map((one) => one.id);
      desks[desk] = await work.step(`hand ${ids.length} to ${desk}`, () => handTo(desk, ids));
    }

    const urgent = read.sorted.filter((one) => one.urgency === "today" || one.wantsMoneyBack);
    return { sorted: read.sorted.length, urgent: urgent.map((one) => `${one.id}: ${one.inAWord}`), desks };
  },
  response: (r) =>
    r.sorted === 0
      ? "nothing came in"
      : `${r.sorted} sorted into ${Object.keys(r.desks).length} desk(s)` +
        (r.urgent.length ? `, ${r.urgent.length} for today: ${r.urgent.join("; ")}` : ""),
});
```

Most jobs need less than this, and some need no model at all. The test for
which you are writing is in [Step, model, agent](https://chloejs.org/docs/primitives).

## When the model should choose the order

Sometimes what to look at next depends on what the last answer said. Then a
job hands a model a goal and some tools, and keeps the limits in the file:
which tools, which calls may run, how many turns and how many dollars.

```ts
export default defineJob({
  id: "why-they-left",
  cron: every.monday.at("09:00"),
  timezone: "America/New_York",
  description: "Works out why good customers stopped ordering, and says what to do about each one.",
  run: async (work) => {
    const quiet = await work.step("find who went quiet", () => goneQuiet(QUIET_DAYS));
    if (quiet.length === 0) return { looked: 0, found: [] };

    const past = note(work.agentId, "why-they-left", Findings);
    const already = await work.step("read what was found before", () => past.read());

    const found: { at: string; customer: string; why: string; next: string }[] = [];
    for (const one of quiet.slice(0, AT_MOST)) {
      const theirs = ({ customer }: { customer: string }) =>
        customer === one.id ? "approved" : { type: "denied" as const, reason: `${one.id} is the customer being looked into, and ${customer} is not` };
      const reason = await work.agent(`work out why ${one.name} stopped ordering`, {
        prompt:
          `${one.name}, customer ${one.id}, has been buying since ${one.since} and last ordered on ` +
          `${one.lastOrder}. Work out the most likely reason they stopped, and what somebody here should do ` +
          `about it. Look things up in whatever order the answers suggest, and say what you actually saw.`,
        tools: {
          ordersTheyPlaced: tool({
            description: "Every order this customer has placed, newest first.",
            inputSchema: z.object({ customer: z.string().describe("The customer's id, like c-12") }),
            execute: async ({ customer }) =>
              (await ordersBy(customer)).map((order) => ({
                id: order.id,
                placed: order.placed,
                shipped: order.shipped ?? "never",
                promised: order.promised,
                total: money(order.total),
              })),
          }),
          whatTheyWroteIn: tool({
            description: "Messages this customer sent us, newest first.",
            inputSchema: z.object({ customer: z.string().describe("The customer's id, like c-12") }),
            execute: ({ customer }) => messagesFrom(customer),
          }),
          whatWasFoundBefore: tool({
            description: "What earlier runs concluded about this customer.",
            inputSchema: z.object({ customer: z.string().describe("The customer's id, like c-12") }),
            execute: ({ customer }) => already.found.filter((each) => each.customer === customer).slice(-3),
          }),
        },
        // The tools say what it may do. This says who it may do it to: the
        // model writes the arguments, so a limit on them is checked when it
        // asks rather than when the job is written. One customer's file is not
        // a reason to open everybody's.
        toolApproval: { ordersTheyPlaced: theirs, whatTheyWroteIn: theirs, whatWasFoundBefore: theirs },
        output: Reason,
        // Both limits, because eight turns of a large model is not a small
        // number. Whichever it reaches first ends the step with an error.
        stopWhen: isStepCount(8),
        budget: 0.05,
      });

      found.push({ at: new Date().toISOString(), customer: one.id, why: reason.why, next: reason.worthACall ? `call them: ${reason.next}` : reason.next });
    }

    await work.step("write down what was found", () => past.write({ found: [...already.found, ...found].slice(-50) }));

    return { looked: found.length, found: found.map((one) => `${one.customer}: ${one.why}`) };
  },
  response: (r) => (r.looked === 0 ? "nobody has gone quiet" : r.found.join(". ")),
});
```

## When a person has to decide

A job can stop, send its question to whoever should answer it, and carry on
when they do, hours or days later, after a restart. That is
[asking a person](https://chloejs.org/docs/asking-a-person).

## Run it

```sh
npx chloe                           # the agents, their cron lines and their chats
npx chloe agent shop                # talk to it
npx chloe agent shop sort-messages  # run one job now
```

Every run writes down each step, what it was handed, what it returned and what
it cost, so you can see which line asked a model and what that came to.

Next: [run it on a schedule](https://chloejs.org/docs/schedules), put it on
[Telegram or Slack](https://chloejs.org/docs/channels), or read [the examples](https://chloejs.org/examples).
