Chloe

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
WhatsApp 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
email 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.