# Step, model, agent

> The three kinds of step a job is made of, which one to use, and the options and limits of each.

Use plain code wherever you can, and give a model room only where a step needs
judgement. A job says which it is doing, step by step:

| | Use it when | What the model decides |
|---|---|---|
| `work.step()` | you know what to do | nothing |
| `work.model()` | you know what to ask | the answer to one question, in a shape you set |
| `work.agent()` | you know only what you want | the order of the work, inside the tools and limits you set |

Reach down that list, not up it. Each one down costs more, takes longer, and can
answer differently tomorrow. Before adding a model step, say in one sentence
what judgement it makes: if the sentence is a rule, write the rule.

## work.step: you know what to do

A query, a request, a calculation, a file, an email. Plain TypeScript, written
down once so a resumed run does not do it twice.

```ts
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(", ")}`,
});
```

Paid for, not shipped, past the promised date: that is a rule, so it is code,
and nothing in the job can spend money or answer differently tomorrow.

A step has 10 minutes, and then fails saying it ran out of time, so one that
hangs fails its run instead of holding its job. Its function is handed a
`signal` that fires then: pass it on, and the request or program really stops,
as in `work.step("fetch the report", ({ signal }) => fetch(url, { signal }), { timeLimit: 30 })`.
`timeLimit` is in minutes, and 0 is no limit. Code that does not pass the
signal on is not waited for: it goes on in the background, and what it returns
is dropped. A loop that never gives the thread back cannot be stopped at all.
`work.signal` is the run's own, and fires when the run ends.

## work.model: you know what to ask

Code gathers the facts, one step asks the model, and code decides what to do
with the answer. Classifying, extracting, summarising, ranking, writing the
words.

```ts
export default defineJob({
  id: "sort-messages",
  cron: every(15).minutes,
  timezone: "America/New_York",
  description: "Every message a customer wrote in is 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("; ")}` : ""),
});
```

| Option | Default | What it controls |
|---|---|---|
| `prompt` | required | The question, with the facts it needs. |
| `output` | required | The shape the answer must have: a zod schema, or an AI SDK output like `Output.choice({ options: ["billing", "sales"] })` or `Output.array({ element })`. `Output.text()` is refused. |
| `instructions` | none | What the model should know before the question. |
| `model` | the job's | A model for this one step. |
| `timeLimit` | 10 minutes | The most the step may take, both tries included. Each model call also has `model.timeLimit`. |

An answer that does not fit the shape is sent back to the model once, saying
what was wrong. If it still does not fit, the run fails. Free text never
reaches your next line of code.

## work.agent: you know only what you want

When what to look at next depends on what the last answer said, hand a model a
goal and some tools. It decides what to call and in what order; everything
else stays in your file.

```ts
export default defineJob({
  id: "why-they-left",
  cron: every.monday.at("09:00"),
  timezone: "America/New_York",
  description: "Every good customer who stopped ordering has a reason and a next step.",
  run: async (work) => {
    const quiet = await work.step("find who went quiet", () => goneQuiet(QUIET_DAYS));
    if (quiet.length === 0) return { looked: 0, found: [] };

    const past = note(work.agentId, "why-they-left", Findings);
    const already = await work.step("read what was found before", () => past.read());

    const found: { at: string; customer: string; why: string; next: string }[] = [];
    for (const one of quiet.slice(0, AT_MOST)) {
      const theirs = ({ customer }: { customer: string }) =>
        customer === one.id ? "approved" : { type: "denied" as const, reason: `${one.id} is the customer being looked into, and ${customer} is not` };
      const reason = await work.agent(`work out why ${one.name} stopped ordering`, {
        prompt:
          `${one.name}, customer ${one.id}, has been buying since ${one.since} and last ordered on ` +
          `${one.lastOrder}. Work out the most likely reason they stopped, and what somebody here should do ` +
          `about it. Look things up in whatever order the answers suggest, and say what you actually saw.`,
        tools: {
          ordersTheyPlaced: tool({
            description: "Every order this customer has placed, newest first.",
            inputSchema: z.object({ customer: z.string().describe("The customer's id, like c-12") }),
            execute: async ({ customer }) =>
              (await ordersBy(customer)).map((order) => ({
                id: order.id,
                placed: order.placed,
                shipped: order.shipped ?? "never",
                promised: order.promised,
                total: money(order.total),
              })),
          }),
          whatTheyWroteIn: tool({
            description: "Messages this customer sent us, newest first.",
            inputSchema: z.object({ customer: z.string().describe("The customer's id, like c-12") }),
            execute: ({ customer }) => messagesFrom(customer),
          }),
          whatWasFoundBefore: tool({
            description: "What earlier runs concluded about this customer.",
            inputSchema: z.object({ customer: z.string().describe("The customer's id, like c-12") }),
            execute: ({ customer }) => already.found.filter((each) => each.customer === customer).slice(-3),
          }),
        },
        // The tools say what it may do. This says who it may do it to: the
        // model writes the arguments, so a limit on them is checked when it
        // asks rather than when the job is written. One customer's file is not
        // a reason to open everybody's.
        toolApproval: { ordersTheyPlaced: theirs, whatTheyWroteIn: theirs, whatWasFoundBefore: theirs },
        output: Reason,
        // Both limits, because eight turns of a large model is not a small
        // number. Whichever it reaches first ends the step with an error.
        stopWhen: isStepCount(8),
        budget: 0.05,
      });

      found.push({ at: new Date().toISOString(), customer: one.id, why: reason.why, next: reason.worthACall ? `call them: ${reason.next}` : reason.next });
    }

    await work.step("write down what was found", () => past.write({ found: [...already.found, ...found].slice(-50) }));

    return { looked: found.length, found: found.map((one) => `${one.customer}: ${one.why}`) };
  },
  response: (r) => (r.looked === 0 ? "nobody has gone quiet" : r.found.join(". ")),
});
```

| Option | Default | What it controls |
|---|---|---|
| `prompt` | required | What you want done, not how. |
| `tools` | required | Everything it may call. Nothing else is reachable from inside the step. |
| `output` | its words, as a string | The shape of its final answer, as for a model step. |
| `stopWhen` | 10 steps | When it must stop, as the AI SDK's: `isStepCount(8)`, `hasToolCall("done")`, or a list. |
| `budget` | none | Dollars it may spend. Checked between turns, so the turn that crosses it is paid for and nothing after. An answer with no price ends the step too, since what it spent can no longer be counted. |
| `toolApproval` | every call allowed | Asked before each call, with the input the model wrote. Below. |
| `instructions` | none | What it should know before it starts. |
| `model` | the job's | A model for this one step. |
| `timeLimit` | 30 minutes | The most the whole step may take. A tool still running is told to stop. Time waiting for a person's yes does not count. |

Reaching `stopWhen`, `budget` or `timeLimit` ends the step with an error saying which, not a
half answer. Every call it made, with its arguments, what came back and whether
it was allowed, is on the run, with the step's cost.

### Which calls may run

`toolApproval` is code, so it can check anything code can: that it is the
customer it was sent to look into, that the amount is under a hundred. Answer
`"approved"`, or `{ type: "denied", reason }`, and the model is told the reason
and carries on.

Give a function per tool, `{ ordersTheyPlaced: theirs }`, and each is handed
that tool's input with its type. One function for every tool,
`({ toolCall }) => ...`, sees the input as `unknown`, so write the check to
refuse a missing field: `amount < 100` refuses when there is no amount,
`!(amount > 100)` allows it. A tool's own `needsApproval` is asked after.

### When a person has to say yes

Answer `"user-approval"`, or mark the tool `needsApproval`, and the run stops
before that call:

```ts
const refund = tool({
  description: "Refund part or all of one order, in cents.",
  inputSchema: z.object({ order: z.string(), amount: z.number().int().min(1) }),
  needsApproval: ({ amount }) => amount > ON_SIGHT,
  execute: async ({ order, amount }) => `refunded ${money(await refundOrder(order, amount))} on ${order}`,
});
```

The run's owner is asked on the channel they use, with what the agent wants to
do and the input it chose. A yes runs the call and the step carries on, without
asking the model again. A no, or no answer in two hours, refuses the call, and
the model is told. A run with nobody to ask refuses such a call, and so does a
chat, because a chat turn does not wait.

## Which one

1. I know the operations and the order: `step`.
2. I know the question and the shape of the answer: `model`.
3. I know the outcome and the tools, and nothing about the order: `agent`.

"Restart it if it is down" is a step. "Say what today looked like" is a model.
"Work out why this failed" is an agent. A whole job that is open ended from
end to end is a [prompt](https://chloejs.org/docs/prompts), and a job that stops for a person is
[asking a person](https://chloejs.org/docs/asking-a-person).
