Pipelines
Smart listeners that turn the burst of new_message events into a sequential, render-ready flow.
Messages arrive from the bot service almost instantly, often in groups. Subscribing to the raw new_message event gives you the burst directly — fine for logging, terrible for rendering.
Pipelines wrap the same stream with logic that orders, paces, and exposes typing states. There are three.
Sequence
$sequence — a sequential flow of messages, ordered as the bot intends them to be displayed.
core.pipelines.$sequence.subscribe(function (message) {
console.log(message);
});
Use this when you want order without artificial delay.
Readable Sequence
$readableSequence — a sequential flow of messages with an extra delay between each one. The delay is derived from the previous message's content, so longer messages "feel" like they took longer to type. Closer to a natural reading cadence.
core.pipelines.$readableSequence.subscribe(function (message) {
console.log(message);
});
Use this for chat-style UIs where messages should arrive at a human pace.
Advanced Typing Sequence
$typingSequence — a sequential flow that includes the bot's typing state. Each tick of the subscription carries information about typing state, delay, and message content — so you can render a typing indicator that turns into the actual message at the right moment.
core.pipelines.$typingSequence.subscribe(function (data) {
if (data) {
console.log(data);
}
});
Use this when you want a "..." typing dot before each bot reply.
Each tick has the shape:
{
state: true | false, // true = "starting to type", false = "finished, render the message"
delay: 0 | <number>, // milliseconds to wait before this tick; `undefined` on the end-tick
message: { … } // the message that will be (or was just) revealed
}
A typical sequence per bot message: a state: true, delay: 0 tick fires when the bot begins typing, then a matching state: false tick fires after the typing delay completes — that's the moment to swap your "..." dots for the rendered message. The departures demo uses exactly this pattern to drive the split-flap flip.
Subscription timing
$readableSequence's built-in pacing delay makes it forgiving for late subscribers: even if you subscribe after core.init() resolves, the first delivery of the welcome message hasn't fired yet, so your subscription catches it. The other two streams aren't so kind.
| Stream | Subscribed before init() |
Subscribed after init() resolves |
|---|---|---|
$sequence |
catches welcome | misses welcome |
$readableSequence |
catches welcome | catches welcome |
new_message |
catches welcome | misses welcome |
Subscribe before core.init() if you need any of the three to deliver the bot's opening messages. If you can only subscribe after init has resolved, $readableSequence is the safe choice.
The stream is bidirectional
Every pipeline emits both bot-sent and user-sent messages. The user's own messages from core.sendMessage(...) echo back through the same subscription a moment after they're sent — they're not filtered out for you.
This is deliberate: it lets a single subscription be the source of truth for the conversation, regardless of which side spoke. Filter with the samurai field if your code only cares about one side:
core.pipelines.$readableSequence.subscribe((msg) => {
if (msg.samurai === undefined) {
// user message — they just answered
} else {
// bot message — render it
}
});
The bidirectional stream also makes a useful pattern possible: counter-driven state machines. Because every user reply arrives in order through the same subscription, you can count user messages and treat each one as a "step" in a local wizard — no extra listeners needed.
let userMsgCount = 0;
const STEPS = [
(text) => setHeroHeadline(text),
(text) => setHeroSubhead(text),
(text) => setBrandColour(text),
// …
];
core.pipelines.$readableSequence.subscribe((msg) => {
if (msg.samurai !== undefined) {
renderBotBubble(msg);
return;
}
// user message — advance the wizard
const step = STEPS[userMsgCount++];
step?.(msg.message);
});
This is the architecture the composer demo uses.
Counter-driven vs extra.id-driven
Counter-driven is the simplest pattern, but it breaks the moment the bot branches, loops, or grows optional blocks. A more robust anchor is extra.id on bot messages — the block identifier from the bot flow. Strip its emission-index suffix and you have a stable key per logical block:
core.pipelines.$readableSequence.subscribe((msg) => {
if (msg.author_type !== "bot") return;
const blockId = (msg.extra?.id ?? "").replace(/_\d+$/, "");
switch (blockId) {
case "welcome": handleWelcome(msg); break;
case "NCLVF_z-G": handleRatingPrompt(msg); break;
// …
}
});
The emission-index suffix matters because a single block can emit multiple messages with different types — a Buttons block sends a text "body" (_0) followed by a dialog row (_1) under the same block id. See Messages → Multi-emission blocks for the full pattern.
Note Dedupe via
msg.key. Pipelines can re-emit a message under certain conditions (initialisation replay, subscriptions registered aftercore.init(), hot-reloading dev). Keep aSetof seenmsg.keyvalues andreturnearly if you've handled one before — every demo in this section does it.