@yaebal/conversation

write multi-step dialogs as a straight line — await cv.waitFor(...) resolves with the next matching update for that key. a coroutine by default; opt into a durable, restart-safe replay engine with one option when you need it.

install

terminal
pnpm add @yaebal/conversation

a first conversation

define each conversation with createConversation(builder), register them by name with conversation({ name: def }) — the same keyed shape as @yaebal/scenes' defs — and start one from any handler with ctx.conversation.enter(name). names are typed from the object you pass in, so a typo is a compile error.

bot.ts
import { Bot } from "@yaebal/core";
import { conversation, createConversation } from "@yaebal/conversation";

const greet = createConversation(async (cv, ctx) => {
  await ctx.send("what's your name?");
  const a = await cv.waitFor("message:text"); // narrowed: a.text is a plain string
  await a.send(`age, ${a.text}?`);
  const b = await cv.waitFor("message:text");
  await b.send(`${a.text} is ${b.text}`);
});

const bot = new Bot(process.env.BOT_TOKEN!)
  .install(conversation({ greet }));

bot.command("greet", (ctx) => ctx.conversation.enter("greet"));
bot.start();
bot.ts
import { createBot } from "yaebal";
import { conversation, createConversation } from "@yaebal/conversation";

const support = createConversation(async (cv, ctx) => {
  await ctx.send("what's the problem?");
  const topic = await cv.waitFor("message:text"); // topic.text: string, no "?? fallback" needed

  const urgency = await cv.form.choice({
    question: "how urgent: low, normal, or fire?",
    choices: ["low", "normal", "fire"] as const,
  });

  await ctx.send(`ticket queued: ${topic.text} / ${urgency}`);
});

const bot = createBot(process.env.BOT_TOKEN!)
  .install(conversation({ support }));

bot.command("support", (ctx) => ctx.conversation.enter("support"));

bot.start();

how it works

unlike grammY's replay-based conversations, the default engine is a coroutine: the builder runs once, detached, and each wait() call parks a real promise until a matching update arrives. no replay means no duplicated side effects — every ctx.send fires exactly once. while a conversation is active it owns the updates its current wait() call would otherwise miss (see "routing" below); state lives only in memory, so it does not survive a restart unless you opt into the durable engine (see below).

waitFor / waitUntil

wait() resolves with whatever update arrives next. waitFor(query) takes a core filter query ("message:text", ":photo", …) and narrows the result exactly like composer.on() — no more a.text ?? "fallback". waitUntil(predicate) takes any sync or async predicate, with a type-guard overload for full narrowing.

wait-for.ts
const echo = createConversation(async (cv, ctx) => {
  // waitFor narrows exactly like composer.on() — no manual "if (!ctx.text) return"
  const a = await cv.waitFor("message:text");
  await ctx.send(`text: ${a.text}`);

  // waitUntil takes any predicate, sync or async — and narrows with a type guard overload
  const photo = await cv.waitUntil((c): c is typeof c & { message: { photo: unknown } } =>
    Boolean(c.message?.photo));
  await ctx.send("nice photo");
});

cv.form: ready-made ask loops

text/int/choice/confirm each send a question, wait for an answer, validate it, and re-ask on invalid input — built on waitFor, so they're just sugar, not a separate mechanism. text and int also accept a standard-schema validator (zod, valibot, arktype) via parse.

form.ts
const signup = createConversation(async (cv, ctx) => {
  const name = await cv.form.text({ question: "your name?" });

  const age = await cv.form.int({
    question: `thanks, ${name}! how old are you?`,
    min: 1,
    max: 120,
    invalid: "age is a number between 1 and 120 — try again",
  });

  const plan = await cv.form.choice({
    question: "pick a plan: free, pro, or team",
    choices: ["free", "pro", "team"] as const,
  });

  const confirmed = await cv.form.confirm({ question: `lock in ${plan}? (y/n)` });
  if (!confirmed) return ctx.send("cancelled");

  await ctx.send(`saved: ${name}, ${age}, ${plan}`);
});

routing: passthrough and commands

conversations are polite by default. an update that doesn't match the currently parked wait()/waitFor() filter falls through to normal handlers, and /commands bypass the conversation entirely — a global /cancel or /help keeps working mid-conversation with zero per-step boilerplate.

routing.ts
bot.install(conversation({ support }, {
  // an update that doesn't match the *currently parked* wait()/waitFor() filter:
  passthrough: true,                 // default: falls through to normal handlers
  // passthrough: false,             // queued instead (bounded by queueLimit)

  // /commands bypass an active conversation, so global handlers keep working
  passCommands: ["cancel", "help"],  // default true (every command)
}));

// a global /cancel that works mid-conversation — no per-step checks needed
bot.command("cancel", (ctx) =>
  ctx.conversation.active ? ctx.conversation.leave() : ctx.reply("nothing to cancel"));

cancellation and timeouts

ctx.conversation.leave() rejects a parked wait() with ConversationExitedError — the builder's finally blocks still run, so cleanup isn't skipped. re-entering a conversation that's already active does the same before starting the new one, so two builders never race on the same chat. every wait call also accepts a timeout, rejecting with ConversationTimeoutError if nothing arrives in time — catch it to handle the timeout yourself, or leave it uncaught to end the conversation.

timeouts.ts
import { ConversationTimeoutError } from "@yaebal/conversation";

const patient = createConversation(async (cv, ctx) => {
  try {
    const answer = await cv.waitFor("message:text", { timeout: 60_000 }); // per-call override
    await ctx.send(`got: ${answer.text}`);
  } catch (error) {
    if (error instanceof ConversationTimeoutError) return ctx.send("timed out — try again");
    throw error;
  }
});

bot.install(conversation({ patient }, {
  waitTimeout: 5 * 60_000, // default for every wait()/waitFor()/waitUntil()/form.* call
}));

// left uncaught, a timeout just ends the conversation — onLeave fires with reason "timeout"

cv.signal is an AbortSignal that aborts on any of the above — pass it to fetch or anything else cancellable.

signal.ts
const fetching = createConversation(async (cv, ctx) => {
  // aborts automatically if the conversation is left, replaced, or times out mid-fetch
  const res = await fetch("https://api.example.com/status", { signal: cv.signal });
  await ctx.send(await res.text());
});

session keys

the default key is per user per chat — safe in groups (each member gets their own conversation) and doesn't follow a user between an unrelated private chat and a group. two other presets cover the rest.

keys.ts
import { conversation, perChat, perChatUser, perUser } from "@yaebal/conversation";

bot.install(conversation({ support }, {
  getKey: perChatUser, // default — one conversation per user *per chat*, group-safe
  // getKey: perChat,  // one shared conversation per chat (any member can answer)
  // getKey: perUser,  // one conversation per user, follows them across every chat
}));

the durable engine

pass a StorageAdapter (any @yaebal/sklad adapter — redis, sqlite, cloudflare kv, json file) as options.storage and the plugin switches engines: instead of parking in memory, every update replays the builder from scratch against a recorded log — history resolves instantly from the log (no real work, no duplicate sends), and once it catches up to "now" it either parks again (checkpointed) or finishes. a restart resumes users exactly where they were.

durable.ts
import { conversation, createConversation } from "@yaebal/conversation";
import { redisStorage } from "@yaebal/sklad";
import Redis from "ioredis";

const support = createConversation(async (cv, ctx) => {
  await ctx.send("what's the problem?");
  const topic = await cv.waitFor("message:text");

  // non-deterministic or side-effecting work goes through cv.external() — its result is
  // recorded once and replayed, never re-executed
  const ticketId = await cv.external(() => createTicketId());

  await ctx.send(`ticket #${ticketId} queued: ${topic.text}`);
});

const bot = new Bot(process.env.BOT_TOKEN!)
  .install(conversation({ support }, {
    storage: redisStorage(new Redis()), // any StorageAdapter<unknown> — see @yaebal/sklad
  }));

// a restart now resumes users mid-conversation: every wait() answer, api call and
// cv.external() result replays from the log instead of firing again
the determinism contract. a durable builder is re-run on every update, so it must reach the same wait()/api-call/cv.external() sequence every time: route all IO through ctx or cv.external(), never branch on outside mutable state, and don't call ctx.api.downloadFile directly (wrap it in cv.external() too — it isn't tracked). a builder that violates this is caught with a clear error instead of silently misbehaving or duplicating a side effect.

hooks

onEnter/onLeave/onError/onOverflow observe a conversation's lifecycle from the options you pass to conversation() — the same shape on both engines.

hooks.ts
bot.install(conversation({ support }, {
  onEnter: (ctx, info) => console.log("entered", info.name, info.params),
  onLeave: (ctx, info) => {
    // info.reason: "finish" | "left" | "replaced" | "timeout" | "error"
    if (info.reason === "finish") console.log("result:", info.result);
    if (info.reason === "error") console.error("conversation crashed:", info.error);
  },
  onError: (error, ctx, info) => reportToSentry(error, { conversation: info.name }),
  onOverflow: (dropped, ctx, info) => console.warn("dropped an update for", info.name),
}));

api

exportdescription
conversation(defs, options?)the plugin. adds ctx.conversation; picks the durable engine when options.storage is set
createConversation<C, R, P>(builder)define a conversation; declare C when the builder needs more than the base Context
perChat / perUser / perChatUsergetKey presets — see "session keys" above
ConversationExitedErrorrejects a parked wait() on leave()/replace — catch it, or let it unwind the builder
ConversationTimeoutErrorrejects a parked wait() after its timeout elapses
ConversationDef / ConversationDefsthe value returned by createConversation, and the record conversation() takes

Conversation (cv)

memberdescription
wait(opts?)resolve with the next update, whatever it is
waitFor(query, opts?)resolve with the next update matching a filter query — narrowed like on()
waitUntil(predicate, opts?)resolve with the next update for which predicate is true (sync, async, or a type guard)
form.text/int/choice/confirm(opts)ask, validate, re-ask on invalid input — see "cv.form" above
external(fn)run non-deterministic/side-effecting work; recorded and replayed under the durable engine
halt()stop the conversation now, as if the builder had returned
ctxthe most recent context — the entering update, then each waited one
signalan AbortSignal that fires on leave()/replace/timeout

ctx.conversation (ConversationControl)

memberdescription
enter(name, params?)start a registered conversation, cancelling one already running for this key first. resolves once the conversation has started — see "enter() and its result" below
active / currentwhether a conversation is running for this key, and its (typed) name — both getters, not methods
leave()cancel the active conversation (no-op without one); resolves once its builder has fully unwound
snapshot()a point-in-time read of the active session (name, params, startedAt, lastActivityAt), or undefined

ConversationOptions

fielddefaultdescription
getKeyperChatUsersession key for an update. undefined disables the plugin for it (and enter() throws)
passthroughtruea non-matching update falls through (true), is queued (false), or a predicate decides
passCommandstruecommands bypassing the conversation: all, an allowlist array, or none
waitTimeoutdefault ms for every wait call; a call's own { timeout } overrides it
queueLimit100how many updates to hold while the builder is busy before the oldest is dropped
storagea StorageAdapter — presence alone switches to the durable replay engine
nowDate.nowclock override, mainly for tests
onEnter / onLeave / onError / onOverflowlifecycle hooks — see "hooks" above

enter() and its result

enter() deliberately does not wait for the conversation to finish — only for it to start. a builder usually parks on a wait() call expecting a later update, and that update arrives through this very dispatch path; a promise that stayed pending until then would deadlock the handler that awaited it (and every later update for that key with it). read the result back via onLeave's info.result instead — it's set whenever info.reason === "finish".

enter-result.ts
const survey = createConversation(async (cv, ctx) => {
  const a = await cv.waitFor("message:text");
  return { answer: a.text }; // read back via onLeave's info.result — see "hooks" above
});

// safe to fire and forget — the recommended style. enter()'s promise resolves once the
// conversation has *started* (bounded, fast), never once it *finishes* (which may need
// updates that arrive through this very handler — awaiting that would deadlock it)
bot.command("survey", (ctx) => ctx.conversation.enter("survey"));
state is in-memory by default (lost on restart), like prompt. for a single follow-up use prompt; for a branching, always-durable wizard use scenes; for a straight-line script — durable or not — use this.

works with or without @yaebal/runner. no deadlock in the sequential loop: the live engine's builder is detached and updates are routed to it, not awaited inside handleUpdate; the durable engine's turn is always bounded (parks or finishes within one update), never waiting on a future one.

testing

conversations test end-to-end with @yaebal/test actors: send the trigger command, answer with user.sendMessage(...), and assert on env.callsTo("sendMessage"). for the durable engine, build a second Composer/TestEnv pair over the same MemoryStorage to simulate a restart. packages/conversation/src/live.test.ts and replay.test.ts cover every behavior on this page.

related

@yaebal/scenes — declarative, always-durable wizards with navigation and sub-scenes · @yaebal/prompt — a one-shot "ask once, handle the reply" · @yaebal/sklad — the storage adapters the durable engine runs on.