Docs
Asking a person
A job stops, sends its question to whoever should answer it, and carries on when they do.
work.ask stops the run, sends a question to a person, and waits. Their next
message back is the answer. chloe can restart while it waits: when the answer
comes, the job runs again from the top, every finished step hands back what it
returned, and ask returns the answer.
export default defineJob({
id: "big-refunds",
cron: every.weekday.at("09:30"),
timezone: "America/New_York",
description: "Small refunds are paid, and a person has decided the ones worth asking about.",
run: async (work) => {
const waiting = await work.step("read the refunds asked for", () => refundsAsked());
if (waiting.length === 0) return { paid: [], asked: 0, held: [] };
const paid: string[] = [];
for (const one of waiting.filter((each) => each.amount <= ON_SIGHT)) {
const amount = await work.step(`refund ${one.id}`, () => payRefund(one.id));
paid.push(`${one.id} (${money(amount)})`);
}
const held: string[] = [];
const big = waiting.filter((one) => one.amount > ON_SIGHT);
for (const one of big) {
// Two days, because the question can sit until somebody is at a desk.
// Nobody answering means the money stays where it is, which is the
// answer that changes nothing.
const yes = await work.ask(`refund ${money(one.amount)} to ${one.customer}?`, {
question: `${one.customer} asked for ${money(one.amount)} back on ${one.order}: "${one.because}". Refund it?`,
answer: z.boolean(),
within: "2d",
otherwise: false,
});
if (yes) {
const amount = await work.step(`refund ${one.id}`, () => payRefund(one.id));
paid.push(`${one.id} (${money(amount)})`);
} else {
held.push(one.id);
}
}
return { paid, asked: big.length, held };
},
response: (r) =>
[r.paid.length ? `paid ${r.paid.join(", ")}` : "nothing paid", r.asked ? `asked about ${r.asked}` : "", r.held.length ? `held ${r.held.join(", ")}` : ""]
.filter(Boolean)
.join(", "),
});The rules around the question are code, so a run with nothing to ask about ends without asking.
| Option | Default | What it controls |
|---|---|---|
question |
required | What they are asked. |
answer |
required | A zod schema the answer must fit, like z.boolean(), z.enum(["small", "large"]), or z.array(z.enum([...])) for any number of them. |
who |
the run's owner | An address, channel:who. Below. |
within |
"2h" |
How long to wait: "30m", "4h", "2d". |
otherwise |
none | What to carry on with when nobody answers. Without it, the job stops and says nobody answered. |
Who. An address is the channel's name and the person's id on it:
| Address | Reaches |
|---|---|
telegram:12345 |
a Telegram user id |
slack:U0123ABCD |
a Slack member id |
discord:123456789012345678 |
a Discord user id, in a direct message |
whatsapp:+447700900123 |
a WhatsApp number, if they wrote in the last 24 hours |
email:someone@example.com |
a new email conversation, with someone in that channel's allowFrom |
web:<visitor> |
a visitor, next time the chat box loads |
The run's owner is whoever started it. For a run the clock started, it is
owner in settings, or else the first entry in allowFrom of the agent's
Telegram, Slack, Discord or WhatsApp channel.
The answer is read by code, not a model. "yes" against z.boolean() is a
yes, and a choice may be written in any case or as its number in the list, with
commas between several ("cheese, 3"). An answer that does not fit is asked
again in plain words. The question goes with a line saying what fits, like
"(small, large)", so a channel with no buttons still says it.
| Channel | One of a list (8 at most) | Several of a list |
|---|---|---|
| Telegram | a button each | a box each to tick, then Send |
| Slack | a button each | a box each to tick, then Send |
| Discord | a button each | a menu that takes several |
| a button each, up to three | typed, with commas between | |
| the dashboard, the chat box | a button each | a button each to press, then Send |
| typed | typed, with commas between |
Pressing one answers and removes the buttons, so nobody answers twice.
While a job waits it does not start again, so the same question is never asked
twice. Answering also works from the dashboard, or with
POST /api/runs/<id>/answer and {"text":"yes"}; GET /api/parked lists the
questions waiting, each with its options and multiple when it has them.
From a chat
In a conversation the model can do the same with chatAsk: a question and 2
to 8 answers, and whether several may be picked. The question ends its reply,
shown the same way as above, and what they pick is their next message, which the
model reads. Nothing waits in between, so it suits a conversation, and a job
that has to act on the answer uses work.ask. Every turn in a conversation has
it, a visitor's on a web page too, and a job's run has nobody to ask, so none
does.
Over HTTP, a chat reply that asks carries buttons, each { label, sends },
and multiple when several may be picked; sending a button's sends is
answering. Several go as one message with commas between, or as a JSON list
when one of them holds a comma.