ChloeGet Started

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.