Docs
Step, model, agent
Three primitives, in the order to reach for them, and the test for which one you are writing.
Use deterministic code wherever you can. Give autonomy to a model only where the task genuinely needs judgement, interpretation, or a sequence nobody can write down in advance.
That is the whole idea, and the three primitives are how a job says which one it is doing.
work.step() |
I know what to do. | You control the workflow and the action. |
work.model() |
I know what to ask. | You control the workflow. The model answers one question. |
work.agent() |
I know what I want. | You control the boundaries. The model picks the order inside them. |
Reach down that list, not up it. Every rung costs more money, takes longer, and can come back different tomorrow than it did today.
work.step, when you know what to do
A query, a request, a calculation, a file, an email, a command. Ordinary TypeScript, written down once so a resumed run does not do it twice.
export default defineJob({
id: "site-check",
cron: every.hour.at(0),
description: "Asks every site whether it answered, and writes down what it said.",
run: async (work) => {
const answers = await work.step("ask every site", () =>
// All at once, so one slow site does not hold up the rest.
Promise.all(SITES.map((site) => ask(site))),
);
const at = new Date().toISOString();
await work.step("write down what they said", () =>
note(work.agentName, "sites", Sites).write({ at, answers }).then(() => at),
);
return { checked: answers.length, down: answers.filter(down).map((one) => one.site) };
},
summary: (r) => (r.down.length === 0 ? `${r.checked} sites, all answering` : `down: ${r.down.join(", ")}`),
});Nothing in that job can spend anything or answer differently tomorrow. Most jobs should look like this, and most of them do not yet.
work.model, when you know what to ask
Code gathers the facts, one named step hands them over with a schema for what must come back, and code decides what to do with the answer. Classifying, extracting, summarising, ranking, turning a mess into a shape, writing the words: all of it is this.
export default defineJob({
id: "morning-note",
cron: every.day.at("07:00"),
timezone: "America/New_York",
description: "Says what the sites did, in two sentences, and only when there is something to say.",
model: "anthropic/claude-haiku-4.5",
run: async (work) => {
const seen = await work.step("read what the check wrote", () => note(work.agentName, "sites", Sites).read());
// The rules, in code, because they are rules: nothing to say means nothing
// is asked and nothing is spent.
const worrying = seen.answers.filter((one) => down(one) || one.ms > SLOW);
if (worrying.length === 0) return { said: "", checked: seen.answers.length };
const said = await work.model("write the morning line", {
system: "You write one or two plain sentences for somebody reading their phone. No greeting, no sign off.",
output: z.object({ line: z.string().min(10).max(300) }),
prompt: [
`Checked at ${seen.at || "never"}. These are the ones worth mentioning:`,
...worrying.map((one) => `- ${one.site}: ${one.status} in ${one.ms}ms`),
"",
`Everything else answered: ${seen.answers.length - worrying.length} site(s).`,
].join("\n"),
});
return { said: said.line, checked: seen.answers.length };
},
summary: (r) => r.said || `${r.checked} sites, nothing worth saying`,
});Two things make it safe to have a model in the middle of a workflow. The answer is validated against the schema, so free text never reaches the next line of code. And the step is named in the file, so the run record can price it.
Before adding one, say in a sentence what judgement it is making. If the sentence turns out to be a rule, write the rule.
work.agent, when you know only what you want
Sometimes the order of the work cannot be known in advance: what to look at next depends on what the last answer said. That is the case for an agent step, and it is the only case for one.
export default defineJob({
id: "investigate",
cron: every.day.at("08:00"),
timezone: "America/New_York",
description: "Works out why a site stopped answering, and says what to do about it.",
run: async (work) => {
const seen = await work.step("read what the check wrote", () => note(work.agentName, "sites", Sites).read());
const broken = seen.answers.filter(down);
if (broken.length === 0) return { looked: 0, found: [] };
const past = note(work.agentName, "findings", Findings);
const already = await work.step("read what was found before", () => past.read());
const found: { at: string; site: string; cause: string; advice: string }[] = [];
for (const one of broken) {
// The tools are the boundary: inside this step the model can look up an
// address, ask the site again and read what was said last time, and it
// can do nothing else.
const finding = await work.agent(`work out what is wrong with ${one.site}`, {
goal:
`${one.site} answered ${one.status} when it was last checked. Work out the most likely reason and ` +
`what somebody should do about it. Look things up in whatever order the answers suggest.`,
tools: [
tool({
id: "look_up_address",
description: "Look up the IP addresses a hostname resolves to.",
inputSchema: z.object({ hostname: z.string() }),
execute: ({ hostname }) => resolve4(hostname).catch((error: Error) => `no address: ${error.message}`),
}),
tool({
id: "ask_the_site",
description: "Ask the site again now, and say what it answered.",
inputSchema: z.object({ url: z.string() }),
execute: ({ url }) => askSite(url),
}),
tool({
id: "what_was_said_before",
description: "What earlier runs concluded about this site.",
inputSchema: z.object({ site: z.string() }),
execute: ({ site }) => already.found.filter((one) => one.site === site).slice(-3),
}),
],
output: Finding,
maxSteps: 8,
});
found.push({ at: new Date().toISOString(), site: one.site, cause: finding.cause, advice: finding.advice });
}
await work.step("write down what was found", () =>
past.write({ found: [...already.found, ...found].slice(-50) }),
);
return { looked: broken.length, found: found.map((one) => `${one.site}: ${one.cause}`) };
},
summary: (r) => (r.looked === 0 ? "nothing to look into" : r.found.join(". ")),
});You give the goal and the tools. The model decides what to call and in what order, and the runtime runs it: ask, run what it asked for, put the answer back, ask again, until it is done.
What stays yours:
- The tools are the boundary. It can call nothing it was not handed.
- The cap is yours.
maxStepsends it, and running out is an error that says so rather than a quiet half answer. - The shape is yours.
outputis validated like any model step. - The record is complete. Every call it made, with its arguments and its answer, is on the run, and the whole step is priced.
An agent step is recorded like any other step, so a run that parks and resumes does not live through it twice.
The test
Ask which of these three sentences is true, and write that one.
- I know the operations and the order. That is
step. - I know the question and the shape of the answer. That is
model. - I know the outcome, the tools, and nothing about the order. That is
agent.
If you can write the rules down, it is code. "Restart it if it is down" is code. "Say what today looked like" is a model. "Work out why this failed" is an agent.
A fourth, for a person
A job can also stop and ask somebody, which is not autonomy at all: it is the opposite. See asking a person.
A whole job that is a prompt
Some work is open ended from end to end, and then the job itself is words rather than code: see a prompt with tools. The difference between that and an agent step is scope. An agent step is a bounded piece of autonomy inside a workflow you control. A prompt job hands over the whole run.