Docs
Run an AI agent on a schedule
Give a job a cron line and it runs on its own, in your timezone, with every run written down and no two runs overlapping.
A scheduled AI agent is a job with a cron line. npx chloe keeps every cron
line of every agent, so there is no separate scheduler, queue or cron table to
set up.
A job on a timer
every from @chloejs/core/timer writes the cron line in words, and
timezone is a real timezone name, so daylight saving is handled for you. This
one checks the orders every two hours and never asks a model, so it costs
nothing to run:
export default defineJob({
id: "stuck-orders",
cron: every(2).hours,
timezone: "America/New_York",
description: "Finds orders that are paid for and late, and tells the warehouse.",
run: async (work) => {
const all = await work.step("read the orders", () => orders());
const late = all.filter((one) => stuck(one));
if (late.length === 0) return { checked: all.length, late: [], worth: 0 };
const worth = late.reduce((total, one) => total + one.total, 0);
await work.step("tell the warehouse", () =>
tellTheWarehouse(
`${late.length} paid order${late.length === 1 ? "" : "s"} past the promised date`,
late.map((one) => `${one.id}: promised ${one.promised.slice(0, 10)}, ${money(one.total)}, ${one.lines.length} line(s)`),
),
);
return { checked: all.length, late: late.map((one) => one.id), worth };
},
response: (r) =>
r.late.length === 0
? `${r.checked} orders, none late`
: `${r.late.length} late, ${money(r.worth)} sitting: ${r.late.join(", ")}`,
});The same works for a job with a model in it. This one sorts the inbox every 15 minutes, and one model call sorts everything that came in, so the price is per run and not per message:
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("; ")}` : ""),
});A prompt on a timer
A job can also be only a prompt, with the words in a markdown file beside it. This one runs every Sunday evening and decides for itself whether there is anything to say:
// The one job here that is a prompt from end to end.
//
// The words are in how-it-went.md beside this file. It is a prompt rather than
// code because what to make of a week of runs is not a rule anybody could write
// down, and because it decides for itself whether there is anything to say.
import { defineJob } from "@chloejs/core";
import { every } from "@chloejs/core/timer";
export default defineJob({
id: "how-it-went",
cron: every.sunday.at("18:00"),
timezone: "America/New_York",
description: "Reads its own notes and says whether anything about the shop has been quietly getting worse.",
markdown: "jobs/how-it-went.md",
});What a schedule promises
- No overlap. A second run of the same job is skipped rather than queued, so a job that takes longer than its interval runs less often, never twice at once.
- No silent mistakes. A cron line that cannot be said evenly, like every 7 minutes, is refused when the file loads, with the reason.
- No lost waits. A job waiting on a person survives a restart and carries on from where it stopped.
- A record. Every run, scheduled or not, writes down each step and what it cost.
Every form every takes is in A job is a workflow.
A job without a cron line runs only when somebody starts it: from the page, a
chat, the API or npx chloe agent <agent> <job>.
Keep it running
npx chloe has to be running for anything to fire. npx chloe install makes it
a service and starts it, a systemd user unit on Linux and a launchd agent on a
Mac, so it keeps going after you close the terminal and after a reboot.
Next: build an AI agent in TypeScript, or see the examples, each on its own schedule.