Chloe

Docs

A job is a workflow

Every option a job takes, what its code is handed, when it runs, and the one rule that makes carrying on after a pause safe.


A job is an async function made of steps, or a prompt. It is written with defineJob and runs only when the agent's jobs lists it.

export default defineJob({
  id: "stuck-orders",
  cron: every(2).hours,
  timezone: "America/New_York",
  description: "The warehouse knows about every order that is paid for and late.",
  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(", ")}`,
});

Options

Option Default What it controls
id required What its runs are filed under, and its name in commands. Do not change it once it has run.
description required One line on what is true when a run is done, not what the job does: "The warehouse knows about every late order", not "Finds late orders". Shown beside the id.
run The job as code. A job has run or markdown, never both.
markdown The job as a prompt: prompt("jobs/<id>.md") for a file in the agent's folder, or the words themselves. See A prompt with tools.
cron none When it runs by itself. Without one, it runs only when somebody starts it.
timezone "UTC" The timezone its cron line is read in, like "America/New_York". Daylight saving is handled.
model the agent's The model this job asks, like "anthropic/claude-haiku-4.5".
args none A zod schema for what it is started with by hand. Below.
state none A zod schema for the store every step shares, which survives a pause.
response the returned string Turns what run returned into words: a chat gets them whole, and the dashboard shows the first line.

What run is handed

What it does
work.step(name, fn, options) Does something once and writes down what it returned. fn is handed a signal.
work.model(name, options) Asks a model one question, answered in a shape you give.
work.agent(name, options) Gives a model a goal and tools, and lets it pick the order, inside limits you set.
work.ask(name, options) Stops and waits for a person. See Asking a person.
work.wait(name, length) Stops for a length of time, like "90m", then carries on.
work.state, work.setState(next) The shared store. A step's result is for the next step; state is what the whole job builds up.
work.args What this run was started with, already checked against args. It does not change.
work.input Where the run came from, and the message. Below.
work.memory The path of the agent's memory folder.
work.agentId, work.runId, work.owner Whose job, which run, and who it is for.
work.signal Fires when the run is stopped, and when it ends.

The options of model and agent, and when to use which, are in Step, model, agent.

Work happens inside a step

A job that waited, on a person or with work.wait, carries on by running the function again from the top, with each finished step handing back what it returned last time instead of running again. So a line outside a step runs again on every resume: if it sends, writes or spends, it does so twice. Code outside a step only decides.

If the job was edited while a run was waiting, the run stops and says the job changed, rather than handing a recorded answer to the wrong step. Start it again.

A run that was going when chloe stopped is closed as failed. Only a run waiting on a person or in work.wait survives a restart.

How long a step may take

A step has 10 minutes, and a whole agent step 30. A step still going then fails, so its run fails and the job is free to run again, instead of a hung step holding its job for ever. timeLimit gives one more or less, in minutes, and 0 is no limit:

await work.step("copy the database", ({ signal }) => copyDb(signal), { timeLimit: 30 });

The function is handed a signal. work.step's fn gets { signal }, and a tool's execute gets the SDK's abortSignal. Pass it to fetch, a child process or anything else that takes one, and it stops when the step runs out of time or the run is stopped. A call that ignores it goes on in the background, and what it returns is dropped. A busy loop on the one thread cannot be stopped at all.

work.signal is the run's own: it fires when the run is stopped by whoever started it, and when the run ends, so nothing started with it outlives the run. Inside a step, use the signal the step hands you, which also fires at the step's limit.

Two runs never overlap

A second run of the same job is skipped, not queued, and a job waiting on somebody does not start again. A job that takes longer than its own interval runs less often than its cron line says.

A trial run

Any job can run as a trial, to see what it would do without anybody hearing from it: the Try button on the dashboard's Jobs page, POST /api/agents/<agent>/trial/<id>, or the agent itself, in a turn its owner wrote, with ownTryJob. It runs the job as its files are now, under the same guard as any other run of it.

It reads for real: mail, web pages, files, models. Anything else is written into the run instead, and nothing goes:

  • Every tool call a model makes, unless the tool says it only reads (onlyReads) or works only on the agent's own folder and memory (own). A tool that says neither is held back, so a new tool needs no code for trials, and one that forgot to say it only reads is held rather than let through. A tool from an MCP server runs when the server marks it read only.
  • What a code step sends through chloe: a message to an address (deliver), an email (deliverEmail, Gmail's send and reply, an email channel's new conversation).

A work.ask ends the trial there, its question held back the same way, and a work.wait does not wait. A run started inside a trial is a trial too.

A code step runs for real, so what it does by itself is done: a file it writes, a request it makes with its own fetch, a service of your own it calls. Send through chloe's services, or through a tool in an agent step.

The run is kept and marked a trial, with everything it held back listed under it on the dashboard. One that held nothing back says so, which is how a job meant to send that sends nothing shows. A trial never counts towards a job's alerts.

When it runs

cron is a cron line, and every from @chloejs/core/timer writes one in words:

every(15).minutes               // */15 * * * *
every(4).hours                  // 0 */4 * * *
every.hour.at(30)               // 30 * * * *
every.day.at("07:00")           // 0 7 * * *
every.day.at("10:45", "22:45")  // 45 10,22 * * *
every.weekday.at("9:30")        // 30 9 * * 1-5
every.monday.at("9:00")         // 0 9 * * 1
every.month.on(1).at("09:00")   // 0 9 1 * *

A line that cannot run evenly is refused as the file loads, with the reason: every(7).minutes would run at :56 and again at :00, every.month.on(31) would skip short months, and two times with different minutes need two jobs. A plain cron string works too.

Without a cron, a job runs when somebody starts it: the Run button on the dashboard, npx chloe agent <agent> <job>, POST /api/agents/<agent>/job/<id>, or /<job id> in a chat.

Starting one with something

The message is always there, in work.input, with nothing to declare:

text what was said, with the /command taken off
from the channel it came through, like "telegram"
chat the conversation's id on that channel
chatTitle the conversation's name, when it has one
user who sent it, by name
userId who sent it, by their id on that channel: an email address, a Telegram id, a visitor's id
thread the conversation as chloe files it
replyTo the message this one answered, when it did

A run the clock started has an empty input.

Anything else is declared in args, a zod schema, and read from work.args:

export default defineJob({
  id: "refunds-for",
  description: "One customer's refunds are paid.",
  args: z.object({ customer: z.string().min(1) }),
  run: async (work) => {
    const customer = work.args.customer;
  },
});

What it is sent is checked before the run exists, so a caller that sent the wrong thing is told at once. Anything not in args is refused rather than dropped, and a job with no args takes only a message.

  • Over the API, as a query string or JSON, the body winning where they overlap. Everything in a query string is a string, so ask for a number with z.coerce.number().
curl -X POST "http://127.0.0.1:3067/api/agents/shop/job/refunds-for?customer=c-42" \
  -H "authorization: Bearer $CHLOE_TOKEN"
  • From a chat, as /<job id> and words. The words fill args in order, and the last field takes the rest of the line, so with args: z.object({ location: z.string() }), /check-weather New York is one location. A command missing a field is answered with how to write it. Only a person's message starts a job this way: a model's reply that reads /<job id> is only sent.

A job with a cron line cannot be handed args on that line, so give each field a default, or leave the cron off.