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 fillargsin order, and the last field takes the rest of the line, so withargs: z.object({ location: z.string() }),/check-weather New Yorkis 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.