Docs
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.
npm install @chloejs/core
npx chloe setupsetup 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 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:
// 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.
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.
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.
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.
Run it
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 nowEvery 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, put it on Telegram or Slack, or read the examples.