Text channels
Put the agent on WhatsApp (Evolution API, Whatsmiau or the Cloud API) and Telegram — one webhook route per channel that verifies, deduplicates, answers durably and in order, converts markdown, splits long replies, attaches media, sends files, asks questions as text, turns proposals into Confirm/Cancel buttons and relays their outcomes — with hooks for the app's own flows.
@dudousxd/nestjs-agent-channels connects the agent to messaging apps. You give it an adapter
(how one provider's webhooks and API look) and two functions — who the sender is, and which thread
the conversation continues — and it mounts a webhook route for it:
AgentChannelsModule.forRoot({ channels: [{ adapter: telegram({ … }), actor, thread }] })
// POST /channels/telegramBackend only. There is no frontend of yours: the messaging app is the client.
For every webhook the route:
- Verifies it is the provider's (a shared token, a signature) —
401otherwise. - Parses the person's message; delivery receipts, the bot's own messages and groups are ignored.
- Deduplicates by the provider's message id, so a retried webhook starts one turn.
- Takes the message and answers
200at once. With a durable engine the message is persisted as a run before the200; the turn runs after the response, so a slow model never makes the provider time out and retry. Messages of one conversation are handled one at a time, in order; each step is retried, and every outgoing message is sent at most once, even when the work runs again after a crash. - Runs the app's hooks:
unknownSenderfor a senderactor()does not know,beforeTurnfor the app's own flows (terms, onboarding, a quota). - Attaches media — an image, a voice note, a document — after downloading it (
prepareMediamay turn a voice note into text) and staging it in the attachment store, or answers why it cannot. - Sends the message to the agent with text-only capabilities by default: every component arrives
as its
fallbackText(Rendering everywhere) — or as a filerenderComponentmakes of it. A text decision ("yes", "confirm #ID") decides a card delivered to this conversation and is answered with its reply instead of starting a turn. - Collects the reply, converts its markdown to the channel's dialect, splits it at the
channel's length limit (on paragraph, line or sentence boundaries) and sends it — every message
checked by
canDeliverfirst. - Sends a question the turn asks (the
asktool, an intake) as text with numbered options, and reads the person's next messages as the answers. - Puts any proposal the turn left pending to the person — with Confirm/Cancel buttons where the channel has them, else with a text instruction in the configured vocabulary — and, once one is approved and executed, relays its outcome.
Use independent approvals
A text channel wants actionApprovalMode: 'independent'. In
the default blocking mode an action call holds its turn open until someone decides it, which a text
channel cannot do: the route sends the text produced so far plus texts.blockingApproval ("This
action needs an approval that can only be given in the app.") and stops reading the run, which stays
parked until it is decided in the app or expires. Buttons and text decisions work only on
independent proposals.
Install
pnpm add @dudousxd/nestjs-agent-channelsIt needs @dudousxd/nestjs-agent and @dudousxd/nestjs-agent-core (peers) and nothing else: the
adapters talk to the providers with the global fetch.
Setup
The raw body
WhatsApp Cloud signs the raw body of each webhook. Create the app with rawBody: true so Nest keeps
it next to the parsed JSON (the other adapters do not need it, and it costs nothing else):
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
const app = await NestFactory.create(AppModule, { rawBody: true });
await app.listen(3000);A WhatsApp Cloud webhook that arrives without a raw body is refused with 401.
Nest's JSON body parser stops at 100 kB. That is plenty for text, but Evolution's "webhook base64" setting puts the media itself in the webhook: raise the limit to your attachment limit (or turn that setting off, and the adapter downloads the file instead):
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
app.useBodyParser('json', { limit: '25mb' });The agent
import { AgentModule } from '@dudousxd/nestjs-agent';
import { ptBrActionProposalText } from '@dudousxd/nestjs-agent-core';
import { Module } from '@nestjs/common';
import { ChannelsModule } from './channels/channels.module';
@Module({
imports: [
AgentModule.forRoot({
// … model, store, actorResolver for the web
actionApprovalMode: 'independent',
backgroundActorResolver: { resolve: ({ actorRef }) => findCurrentActor(actorRef) },
// optional: "sim" / "não" and Portuguese replies for text decisions — and, on text
// channels, Portuguese channel texts by default (see "Language" below)
actionProposalText: ptBrActionProposalText,
// optional: let the model ask clarifying questions — they arrive as text
ask: true,
}),
ChannelsModule,
],
})
export class AppModule {}Who is writing, and which thread
The sender is a phone number (or a Telegram user id). Map it to one of your accounts — only numbers
someone verified (a code sent to that number, say) — and keep one thread per conversation. The
actor function is the authentication of every message: AgentModule's actorResolver is not
used on these routes.
import { AgentChannelsModule, evolutionApi } from '@dudousxd/nestjs-agent-channels';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AccountsModule } from '../accounts/accounts.module';
import { AccountsService } from '../accounts/accounts.service';
import { ChannelThreadsService } from './channel-threads.service';
@Module({
imports: [
AgentChannelsModule.forRootAsync({
imports: [AccountsModule],
inject: [ConfigService, AccountsService, ChannelThreadsService],
useFactory: (config: ConfigService, accounts: AccountsService, threads: ChannelThreadsService) => ({
channels: [
{
adapter: evolutionApi({
url: config.getOrThrow('EVOLUTION_URL'),
instance: config.getOrThrow('EVOLUTION_INSTANCE'),
apiKey: config.getOrThrow('EVOLUTION_API_KEY'),
// a long random string — the only proof a webhook came from your Evolution server
webhookToken: config.getOrThrow('EVOLUTION_WEBHOOK_TOKEN'),
}),
// `from` is the sender's number in international format, digits only: 5511999990000
actor: async (message) => {
const user = await accounts.findByVerifiedWhatsapp(message.from);
return user ? { id: user.id, roles: user.roles } : null;
},
thread: (actor, message) => threads.find('whatsapp', message.conversation, actor.id),
onThreadCreated: (threadId, actor, message) =>
threads.save('whatsapp', message.conversation, actor.id, threadId),
// with `actionProposalText: ptBrActionProposalText`, every other text is already in Portuguese
texts: {
unknownSender: 'Vincule seu WhatsApp na sua conta para conversar comigo.',
},
},
],
}),
}),
],
providers: [ChannelThreadsService],
})
export class ChannelsModule {}ChannelThreadsService is yours: a table of (channel, conversation, actor) → threadId. thread
returning null starts a new thread, reported to onThreadCreated.
Guards
The routes are public by design — each adapter verifies its own requests. A global guard
(APP_GUARD) that requires a session also runs on them: let /channels/* through it.
WhatsApp with Evolution API
Evolution API v2 connects a WhatsApp number through WhatsApp Web.
For Whatsmiau, an Evolution-compatible server whose buttons render, use
whatsmiau().
EVOLUTION_URL=https://evolution.example.com
EVOLUTION_INSTANCE=support
EVOLUTION_API_KEY=…
EVOLUTION_WEBHOOK_TOKEN=…Set the instance's webhook (in the manager, or POST {EVOLUTION_URL}/webhook/set/{instance}) to
https://api.example.com/channels/whatsapp?token=<EVOLUTION_WEBHOOK_TOKEN>with the event MESSAGES_UPSERT and "webhook by events" off (with it on, Evolution appends the
event name to the path). Evolution signs nothing, so the token is what keeps anyone else from posting
messages "from" any number. The adapter also reads it from the url (/channels/whatsapp/<token>), an
Authorization: Bearer header or an x-webhook-token header. webhookToken: false turns the check
off — only for a server nothing outside can reach.
What the adapter reads: messages.upsert with fromMe === false (on data.key or data; a
message with no fromMe at all counts as incoming only with data.status: 'received', and
fromMe: true never does — the instance's own messages are never answered), text from message.conversation or message.extendedTextMessage.text,
button and list replies (buttonsResponseMessage, templateButtonReplyMessage,
listResponseMessage, interactiveResponseMessage), and media (imageMessage, audioMessage,
videoMessage, documentMessage — also inside documentWithCaptionMessage — and stickerMessage,
with the caption as the text). Media is downloaded from the webhook's inline base64 when Evolution's
"webhook base64" setting is on, else from mediaUrl when the server stores media (S3/MinIO; the
apikey is only sent when that url is on the Evolution server itself), else with
POST /chat/getBase64FromMediaMessage/{instance}. It ignores the instance's own messages, other
instances, broadcasts and newsletters, and groups unless groups: true (the sender is then the
participant).
A chat WhatsApp addresses by LID (key.remoteJid: '<id>@lid', key.addressingMode: 'lid') is read
by its phone number when Evolution sends it alongside (key.remoteJidAlt: '<phone>@s.whatsapp.net',
older versions senderPn; Whatsmiau addresses by phone and sends the LID in key.remoteLid): from is the phone's digits and conversation the phone jid — so
replies go to the phone (sendText's number is a phone number), and the chat keeps one
conversation whether a message arrives addressed by phone or by LID. Without the phone, both are the
@lid jid.
Buttons on Evolution
Evolution has a buttons endpoint (POST /message/sendButtons/{instance}), but WhatsApp may not
render buttons sent from a WhatsApp Web session. Evolution (Baileys) interactive buttons were not
rendered in our test with Evolution API 2.3.7 (Android recipient): they go out as a
viewOnceMessage › interactiveMessage (nativeFlow), and the phone showed nothing at all — not
the buttons, not the text. Evolution still stores the message as sent (status PENDING /
SERVER_ACK), so its delivery status cannot tell you it was not shown.
So evolutionApi sends no buttons by default: a proposal arrives as its summary and a text
instruction ("Reply yes to confirm or no to cancel."), which always works. Leave them off.
buttons: true opts in; then the buttons message carries the text instruction too (in its
description, under the summary), so a phone that shows the message but not its buttons can still
answer by text — text decisions work whether buttons are on or not. A definite refusal (400,
403, 404, 422) from the buttons endpoint falls back to the text-only message; a timeout or
5xx does not (the buttons may have been delivered).
WhatsApp with Whatsmiau
Whatsmiau is an Evolution-compatible server built on
whatsmeow (a fork, verbeux-ai/whatsmeow) instead of
Baileys — and its buttons render: given the same sendButtons payload that the phone never showed
from Evolution, Whatsmiau delivered a buttons card with Confirmar/Cancelar. whatsmiau() is the Evolution
adapter with two different defaults:
- Buttons on (
buttons: falseturns them off). Since they render, the buttons card carries no text instruction — just the summary and Confirmar/Cancelar (typing sim/não still works). A4xxfalls back to the text message, instruction included, and so doesbuttons: false. - The route prefix is added for you. Self-hosted Whatsmiau serves Evolution's routes under
/v1(/v1/message/sendText/{instance},/v1/message/sendButtons/{instance},/v1/webhook/set/{instance}), sourlis the host alone; a url that already ends in a version (/v1, or the hosted service'shttps://api.whatsmiau.dev/v2) is used as is.
Everything else — the options, the webhook token, what is parsed (LID chats included), media — is
evolutionApi's, and the adapter is named 'whatsapp' too. Point the instance's webhook
(POST /v1/webhook/set/{instance}, event MESSAGES_UPSERT) at the route with the token.
Whatsmiau (Go, omitempty) leaves fromMe out of an incoming message instead of sending false,
so its messages are taken as incoming by data.status: 'received' (the phone in key.remoteJid,
the LID in key.remoteLid). A webhook that carries no message to answer is logged on the
AgentChannels logger with the event and the reason, never the content — warn when it looked like
a person's message that could not be read (no fromMe and another status), debug otherwise (the
instance's own messages, other events, groups). A custom adapter gives its reason through the
optional ignored(body).
Whatsmiau also forwards a button press without the button's id: messageType: 'buttonsResponseMessage' with only the label in message.conversation. The adapter marks it
buttonWithoutId, and the handler maps the label (texts.approve / texts.reject) to the proposal
card it can only have come from: the handler remembers the cards it sent to each conversation (in the
channel store, 24 h), and when exactly one of them is still pending, the press decides it — even with
other proposals pending in the thread. With several such cards it never guesses: the label goes on as
a text decision, which asks for the #id. The default labels are words of the matching vocabulary
(Confirm/Cancel, Confirmar/Cancelar), so a typed label decides as well.
import { whatsmiau } from '@dudousxd/nestjs-agent-channels';
// a channel in AgentChannelsModule's `channels`, as above
adapter: whatsmiau({
url: config.getOrThrow('WHATSMIAU_URL'), // http://whatsmiau:8080 → http://whatsmiau:8080/v1/…
instance: config.getOrThrow('WHATSMIAU_INSTANCE'),
apiKey: config.getOrThrow('WHATSMIAU_API_KEY'),
webhookToken: config.getOrThrow('WHATSMIAU_WEBHOOK_TOKEN'),
}),WhatsApp Cloud API
Meta's Cloud API signs every webhook and renders reply buttons (up to 3).
import { whatsappCloud } from '@dudousxd/nestjs-agent-channels';
// one more entry in `channels`
{
adapter: whatsappCloud({
phoneNumberId: config.getOrThrow('WHATSAPP_PHONE_NUMBER_ID'),
accessToken: config.getOrThrow('WHATSAPP_ACCESS_TOKEN'),
appSecret: config.getOrThrow('WHATSAPP_APP_SECRET'), // X-Hub-Signature-256 is an HMAC under it
verifyToken: config.getOrThrow('WHATSAPP_VERIFY_TOKEN'), // what you type as "Verify token"
name: 'whatsapp-cloud', // the route: /channels/whatsapp-cloud (default name: 'whatsapp')
}),
actor, thread, onThreadCreated, // as above
}Point the app's webhook at https://api.example.com/channels/whatsapp-cloud and subscribe it to the
messages field. The subscription check (GET ?hub.mode=subscribe&hub.challenge=…) reaches the same
route and is answered with the challenge. The signature is computed over the raw body
(rawBody: true). Messages for another phone_number_id and status updates are
ignored. Media (image, audio, video, document, sticker) is downloaded in two steps with the
access token: GET /{media-id} for a short-lived url, then that url. Remember the
24-hour window:
the route only answers people who just wrote, so it stays inside it, but an outcome relayed much later
may need a template.
Telegram
import { telegram } from '@dudousxd/nestjs-agent-channels';
{
adapter: telegram({
botToken: config.getOrThrow('TELEGRAM_BOT_TOKEN'),
secretToken: config.getOrThrow('TELEGRAM_WEBHOOK_SECRET'), // [A-Za-z0-9_-], 1–256 characters
}),
actor: (message) => accounts.findByTelegram(message.from), // from = the Telegram user id
thread, onThreadCreated,
}Register the webhook once:
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-H 'content-type: application/json' \
-d '{"url":"https://api.example.com/channels/telegram","secret_token":"'"$TELEGRAM_WEBHOOK_SECRET"'","allowed_updates":["message","callback_query"]}'Telegram sends the secret back as X-Telegram-Bot-Api-Secret-Token. The adapter reads private-chat
text messages, media (the largest photo size, document, voice, audio, video, video_note,
sticker, with the caption as the text — downloaded through getFile, which serves files up to
20 MB) and inline-keyboard presses (groups: true for groups), answers each press with
answerCallbackQuery and removes the keyboard so it cannot be pressed twice. Replies are MarkdownV2
with every reserved character escaped; when Telegram still refuses the formatting, the same text is
sent again unformatted. markdown: false sends plain text throughout. The update id is the dedupe
key.
What each channel can do
evolutionApi / whatsmiau | whatsappCloud | telegram | |
|---|---|---|---|
| Verification | shared token (query, url segment or header) | X-Hub-Signature-256 HMAC of the raw body; GET hub.challenge | X-Telegram-Bot-Api-Secret-Token |
| Markdown | WhatsApp (*bold*, _italic_, ~strike~, ```) | MarkdownV2, or none | |
| Message length | 4096 (maxLength) | 4096 | 4096 |
| Proposal buttons | off for evolutionApi, on for whatsmiau (buttons, up to 3; text instruction included for evolutionApi only) | reply buttons, up to 3 (20-character labels) | inline keyboard |
| Inbound | text, extended text, button and list replies, media | text, interactive button/list replies, template quick replies, media | text, callback_query, media |
| Media download | inline base64, mediaUrl, or getBase64FromMediaMessage | Graph API media id → url | getFile (≤ 20 MB) |
| Ignored | own messages, broadcasts, groups (unless groups) | other numbers, statuses | bots, edits, groups (unless groups) |
Markdown conversion reads the model's markdown and the components' Slack-style fallback text
together: **bold** and *bold* both become the channel's bold, [label](url) becomes
label (url) where the channel has no link syntax, headings become bold lines and */+ bullets
become - (WhatsApp) or • (Telegram). Code is left alone. toChannelMarkdown(text, 'whatsapp' | 'telegram' | 'none') and splitMessage(text, maxLength) are exported.
Components drawn natively
With AgentGenuiModule.forRoot({ channels }) configured for a channel's kind (whatsapp, telegram), components arrive as that channel's own messages — text, reply buttons, lists, chart images — and a pressed button is the user's next turn. Adapters report kind and capabilities.lists, and take a list OutboundMessage.
Proposals: buttons and text
When a turn leaves a proposal pending, the route sends its confirmation (the title and detail the tool's preflight or presentation gave it) after the reply:
- With buttons —
ConfirmandCancel(texts.approve/texts.reject), whose ids areagora:approve:<ref>/agora:reject:<ref>(refis the proposal id's last 32 characters, so it fits Telegram's 64-bytecallback_data). A press is matched against the thread's proposals (AgentService.listActionProposals) and decided throughAgentService.decideActionProposal— the same tenant-scoped, policy-checked decision the web's approve/reject routes make — recorded withviaset to the adapter's name ('whatsapp','telegram'). The reply is the configuredactionProposalText.repliestext. - Without buttons — the confirmation plus
texts.instruction({ approve, reject }), whereapprove/rejectare the first words of the configured vocabulary (yes/no, orsim/nãowithptBrActionProposalText), with#<proposalId>appended when the turn left more than one.
texts.footer (a string, or a function of the proposal) adds a short line to every card — "Valid for
5 minutes." — as the provider's footer where it has one (Evolution/Whatsmiau sendButtons, the Cloud
API's interactive message), else under the text; the text fallback ends with it too.
Link cards
A card whose action is opening a link — its confirmation sets link (ToolConfirmation.link, e.g. a
sign-in at another service) — is sent as one text message: the summary (texts.proposal) with
the full link appended unless the summary already shows it, then texts.linkInstruction ("Open the
link to continue." / "Abra o link para continuar."), then — when the proposal expires —
texts.linkValidity(minutes) ("The link works for up to 15 minutes." / "O link vale por até 15
minutos.", from the approval's expiresAt). No buttons, no reply commands, no footer: finishing at
the link decides it, so the card is not remembered for text decisions and a "yes" decides nothing.
Text decisions are scoped to the conversation
A text decision on a channel — "yes", "no #ID", a button label typed or forwarded without its id —
only decides a proposal whose card was delivered to that conversation (the route remembers the
cards it sent to each conversation in the channel store, 24 h, up to 20). The channel reads the
decision itself and starts turns with AgentService.send(params, { textDecisions: false }), so the
agent's own text decisions — which would reach any pending proposal of the thread — never run on a
channel:
- one delivered card pending → it is decided (
via= the adapter's name), with the configured reply; - several →
texts.ambiguousDecision(ids, commands)asks for the#ID; - a proposal pending in the thread that was not delivered here (made in the web app, say), or an
#IDnaming one →texts.noPendingConfirmation("There is nothing waiting for your confirmation here."), and nothing is decided; - nothing pending at all → "yes" is an ordinary message, and starts a turn.
allowRemember: false refuses "always in this conversation" (texts.rememberRefused): every action
needs its own confirmation on that channel. Decisions are read from the message after
prepareMedia and transformInbound, so a transcribed voice note decides like typed text.
After an approval (by button or text) the route waits up to outcomeTimeoutMs (default 60 s; 0
turns it off) for the worker to execute it, then sends what the tool's presentation said, else
texts.actionSucceeded ("Done."), or texts.actionFailed.
formatOutcome(proposal, { text, texts }) decides what an executed proposal says, as a list sent in
order — the outcome, then follow-ups. A reply { text, raw: true } goes exactly as given, alone in
its message (no markdown conversion): a message the person forwards as is.
// in a channel's options
formatOutcome(proposal, { text }) {
const forward = (proposal.execution as { result?: { forward?: string } } | null)?.result?.forward;
if (!forward) return null; // the default: [text]
return ['Done! Forward the message below:', { text: forward, raw: true }];
},The whole list is relayed once (the route's wait and the settled hook share one claim), and
canDeliver is asked for each message with kind: 'outcome' and the proposal's actorRef.
Outcomes that settle later
An action that runs after that wait — approved in the web app, retried by the worker, slow — is
relayed when the proposal worker settles it. Nothing to wire: AgentChannelsService subscribes to
ActionProposalWorkerService.onSettled on boot.
Every turn the route starts carries pageContext.channel = { name, conversation } (merged into your
pageContext), and a proposal records its turn's page context. When the worker settles a proposal,
the module reads it, finds the channel configured under that name and sends the outcome text to that
conversation. Proposals from other surfaces (no channel) and channels this process does not have
are ignored, so it is safe next to a web chat. The route's own wait and the settled hook claim the
same key in the channel store, so an outcome is sent once even when both see it — across replicas
too, with a shared store.
Turns the server continues
A turn the channel did not start — the server continued the conversation on its own, e.g. after a
sign-in completed at a link card's link — is delivered with
AgentChannelsService.follow(address, { runId, threadId, actor }): address is the channel address
the conversation's turns carry (pageContext.channel, { name, conversation, … }). The run's stream
is read and delivered like a turn's (a resume-style job: durably under a durable engine, in order with
the conversation's messages), once per runId however often it is asked. It answers false when no
channel of that name is mounted in this process.
The module binds the service to AGENT_CHANNEL_FOLLOWER (from @dudousxd/nestjs-agent-core), global,
so the code that continues turns needs no import of this package:
constructor(@Optional() @Inject(AGENT_CHANNEL_FOLLOWER) private readonly follower?: ChannelFollower) {}
// after starting the continued turn
await this.follower?.follow(proposal.executionContext?.pageContext?.channel, { runId, threadId, actor });Where the worker runs
The worker runs in every replica except those with surface: 'http'. Import AgentChannelsModule
(with the same channels) in the replicas that run it, or their outcomes are not relayed. Your own
actionProposalWorker: { onSettled } hook still runs, next to the module's.
AgentChannelsService.deliverOutcome(proposal) relays one by hand (true when it sent).
Media
A message with an image, a voice note, a video or a document is attached to the turn the way an
upload in the web chat is: the route checks the file's type and declared size against the
attachment limits
(AgentService.attachmentLimits()), downloads it through the adapter — never past the size limit —
stages it with AgentService.stageAttachment(actor, file) (the same checks as
POST /agent/attachments) and sends the message (the caption, or nothing) with
attachments: [{ mediaId }]. Bind an attachment store for this — AgentMediaAttachmentsModule or
your own AGENT_ATTACHMENT_STAGING (File uploads).
Two hooks shape it:
mediaLimits(limits, media)— the limits for one file: letaudio/oggthrough for a hook that transcribes it.prepareMedia(file, media, context)— after the download:{ text }reads the file as text (a voice note's transcript — added to the message, not attached),{ file }attaches another file (converted, resized),{ refuse: reason }refuses it; nothing attaches it as is.
Then transformInbound({ text, attachments }, context) sees the message as it goes to the agent —
add a note about the attachments, or a default question for an image sent without a caption.
// in a channel's options
mediaLimits: (limits, media) =>
media.kind === 'audio' && limits
? { ...limits, allowedContentTypes: [...limits.allowedContentTypes, 'audio/ogg'] }
: limits,
prepareMedia: async (file, media) =>
media.kind === 'audio' ? { text: await speech.transcribe(file.data) } : undefined,
transformInbound: ({ text, attachments }) => ({
text: attachments.length > 0 && text === '' ? 'What is in this file?' : text,
attachments,
}),What cannot be attached gets a short answer instead (texts.mediaRefused(reason, media, limits)): no
attachment store ('disabled'), a type outside the allowlist ('type' — a WhatsApp voice note is
audio/ogg, which the default allowlist does not take, so the person is asked to type), a file past
the size limit ('size'), or a failed download ('failed'). A caption still reaches the agent
without its file; a file with no caption that could not be attached starts no turn.
Questions
When a turn asks a question set — the ask tool or an intake
— the route sends the text produced so far, then the questions one at a time: the prompt in bold,
numbered options, and how to answer.
Two quick questions.
*(1/2) Which size?*
1. Small
2. Large
Reply with the number of your choice.
Reply *skip* to keep: Small.While a question waits, the conversation is not held: the turn is parked, the next message is read as
the answer, and the last answer resumes the run, whose reply is then read from its stream. The
person's next message is the answer, not a new turn: a number or an option's label (several,
separated by commas, for a multiple choice), a typed value for a typed question or one that allows
free text, or skip (pular in Portuguese) to keep the pre-picked answers. An answer that does not fit is refused with the
reason and the question is asked again. After the last one the route resumes the run with
AgentService.answer (answeredVia = the adapter's name), and the turn's reply arrives as usual. A
question nobody answers within questionTimeoutMs (default 30 minutes) is skipped
(AgentService.skip), and the agent proceeds on its own assumptions. The question in progress lives
in the channel store, so the answer may land on another replica. Wording: texts.questions
(pickOne, pickMany, orWrite, skipWord, keep, invalid, counter).
Language
Everything the channel says on its own — the Confirm/Cancel labels, the reply instruction, how
questions are worded, media refusals, the blocking-mode notice, outcome and failure texts — comes from
texts, and two sets ship:
DEFAULT_CHANNEL_TEXTS— English.ptBrChannelTexts— Brazilian Portuguese ("Responda sim para confirmar ou não para cancelar.", "Responda com o número da sua escolha.",pular, …; its questions alone areptBrChannelQuestionTexts).
The reply words come from the agent's actionProposalText.vocabulary, so the two must speak the same
language. They do by default: when the agent's actionProposalText is ptBrActionProposalText (its
vocabulary carries language: 'pt-BR'), each channel starts from ptBrChannelTexts; otherwise from
the English texts. What you pass as texts overrides that base part by part — texts: { unknownSender: '…' } keeps everything else in Portuguese. A vocabulary of your own can set
language ('pt-BR', 'en') to pick the base; channelTextsFor(vocabulary) returns the set it
picks. For another language, pass a full texts.
import { ptBrChannelTexts } from '@dudousxd/nestjs-agent-channels';
// in a channel's options
texts: { ...ptBrChannelTexts, actionSucceeded: 'Feito! ✅' },Durable processing
When the agent runs durably (AgentModule.forRoot({ durable: true }) with AgentDurableModule and
a DurableModule — see Human-in-the-loop and durability),
the channels run on the app's WorkflowEngine — nothing to configure (durable: false opts out;
durable: engine names one). @dudousxd/nestjs-durable-core is an optional peer, looked up only
then.
- Persisted before the
200. Each message is started as a run of theagora.channel.jobworkflow under an id derived from the provider's message id (agora.channel:<channel>:message:<id>) before the webhook is acknowledged. An engine (or store) that cannot take it answers500, the message id is released, and the provider retries. - One at a time per conversation. The workflow is a singleton per
<channel>:<conversation>: a second message waits (durably, in order) until the first one's reply was sent — the per-conversation lock a queue job used to need. A question the turn asks does not hold it: the answer is the next message. - Phases as steps, retried. A job is three checkpointed steps —
prepare(the sender,unknownSender,beforeTurn, the thread),act(an answer, a press, a decision, or the turn: media,transformInbound,AgentService.send) andread(the turn's stream, delivered). Each is retried with backoff (retry: { attempts, backoffMs, maxBackoffMs, retryable }— 3 attempts from 1 s by default; a refusal that retrying cannot change, anHttpExceptionor a provider4xx, is not retried). What still fails goes toonError. - Recovered after a crash. A process that dies mid-message leaves its run to the engine's recovery, which resumes it on another process (or the restarted one): completed steps are not run again, the turn is never started twice (its run id is kept under the message id), and the reply of a turn that finished while the process was down is read from the run's stream (which replays from its start on a shared sink) and sent.
- Never answered twice. Every outgoing message takes a numbered slot in the channel store before it is sent, so a step that runs again skips what already went; a send that fails frees its slot for the retry. The outcome of a proposal is claimed once, as before.
- Question timeouts are an
agora.channel.timerrun that sleeps on a durable timer, outside the conversation's order, then skips the question and sends the rest of the run.
The recovering process must know the channel: import AgentChannelsModule (with the same channels)
in every process that drives durable runs — the worker pods too, not only the HTTP ones. A process
that has not registered the channel workflows leaves their runs for one that has.
What can still be lost is a message whose send was in flight when the process died (sent at most
once: it is not repeated). Hooks before the reply (actor, unknownSender, beforeTurn,
prepareMedia, transformInbound, onTurnStarted) may run again when a step is retried or
recovered — make their side effects idempotent (key them by message.id).
Without a durable engine the same pipeline runs in the replica that took the webhook, after the
200: in order per conversation, with retries and the same at-most-once sends, but nothing survives
a restart — a process that stops mid-turn loses that reply (the provider will not retry an
acknowledged webhook).
Hooks
Generic hooks for what an app does around the conversation — each optional, per channel:
| Hook | When | Returns |
|---|---|---|
unknownSender(message, context) | actor() mapped the sender to nobody | replies to send (an onboarding step, a "link your number" link), or nothing. Default: texts.unknownSender. |
beforeTurn(message, actor, context) | before anything else for a known sender — answers, presses, decisions, turns | 'continue' (or nothing), 'stop', { reply } or { replies } (sent, then stop). Terms to accept, an account to finish, a quota. |
canDeliver(delivery) | before every message sent — replies, cards, questions, relayed outcomes | false drops it. delivery has the conversation, the outbound message, kind (reply, card, question, outcome, gate, notice), the actor / actorRef, the inbound message and the proposal. Re-check that the number still belongs to the account. |
texts as a function | per message (and per relayed outcome) | the texts for { actor, message, proposal } — the actor's locale. |
onTurnStarted({ runId, threadId, actor, message, queued }) | a message started (or queued) a turn | — record its channel, start a meter. |
onTurnEnded({ channel, conversation, actor, message, runId, threadId, outcome, error }) | always, once this process is done with a message (or with a run read without one: resumed after its question, or followed), whatever it came to | — stop what you started for it (a "typing…" presence, a meter). outcome: replied (the stream ended — possibly having sent nothing), failed (a failed turn, or the job failed after its retries: error set), timeout, blocked, parked (waits for an answer; the resumed run ends with its own call, message: null), stopped (unknown sender or beforeTurn — no turn), handled (an answer, a press, a decision — no turn to read), interrupted (a durable engine took the job away mid-way). Per attempt: a durable job that runs again (a crash, a lease taken over) tells each process that ran it. Not called for a question's timer itself. What it throws is logged and ignored. |
uiCapabilities + renderComponent(component, context) | a component the turn drew | a file (a chart as an image), text, several, or nothing for its fallbackText. |
mediaLimits, prepareMedia, transformInbound | an inbound file / the message going to the agent | see Media. |
formatOutcome(proposal, { text, texts }) | an executed proposal | the outcome and its follow-ups, in order. See Proposals. |
onWebhook(event) | every webhook request | — { status: 'accepted' | 'duplicate' | 'ignored' | 'unauthorized' | 'challenge' | 'method_not_allowed' | 'failed', reason, event, accepted, duplicates, messages }: counters and logs without parsing the body again. |
A hook's reply (ChannelReply) is a string in the model's markdown (converted and split),
{ text, raw: true } (sent exactly as given), or { media, caption?, fallbackText? } — a file.
// a channel's options
{
adapter: whatsapp,
actor: (message) => accounts.actorFor(message.from), // null → unknownSender
unknownSender: async (message) =>
(await leads.isLead(message.from)) ? leads.nextStep(message) : `Link your number: ${linkUrl()}`,
beforeTurn: async (message, actor) =>
(await terms.accepted(actor)) ? 'continue' : { reply: terms.text(actor) },
canDeliver: async ({ conversation, actor, actorRef }) =>
(await accounts.actorFor(phoneOf(conversation)))?.id === (actor?.id ?? actorRef),
texts: ({ actor }) => textsIn(localeOf(actor)),
onTurnStarted: ({ runId }) => usage.recordChannel(runId, 'whatsapp'),
onTurnEnded: ({ conversation }) => presence.stopTyping(conversation), // started in `acknowledge`
onWebhook: ({ status, reason }) => health.record(status, reason),
thread,
}Components as files
The turn negotiates components with uiCapabilities (default { components: [] }: all arrive as
their fallbackText). renderComponent turns one into what the channel sends — an image of a chart,
say — or returns nothing for its fallbackText:
// a channel's options
uiCapabilities: { components: [{ name: 'GrowthChart', versions: [1] }] },
renderComponent: async (component, { rendered }) =>
rendered < 3 && drawable(component.name)
? { media: { kind: 'image', url: await charts.imageUrl(component), contentType: 'image/png' } }
: null,
texts: { componentsOnly: () => 'Here is the chart.' },Rendered components are sent when the turn ends (or asks a question), before its text; a later frame
with the same id replaces the earlier one; a failed turn sends none of them.
texts.componentsOnly(components) is said after them when the turn wrote no text.
Sending files
OutboundMessage may carry a file: { text: caption, media: { kind: 'image' | 'document' | 'audio' | 'video', url?, data?, contentType?, filename? } }. The built-in adapters declare
capabilities.media and send it:
| by URL | by bytes | |
|---|---|---|
evolutionApi / whatsmiau | POST /message/sendMedia/{instance} (media: url) | the same, media as base64 |
whatsappCloud | { type, [type]: { link, caption } } | uploaded first (POST /{phone-number-id}/media), then by id |
telegram | sendPhoto / sendDocument / sendAudio / sendVideo with the URL | the same, multipart/form-data |
A channel without capabilities.media gets the reply's fallbackText (or caption) as text instead.
Options
AgentChannelsModule.forRoot({ channels, store?, durable?, path? }) (or forRootAsync({ imports, inject, useFactory, path? }), where useFactory returns { channels, store?, durable? }):
| Option | Default | Meaning |
|---|---|---|
channels | — | One entry per channel (below). Adapter names must be unique: the name is the route and the decision's via. |
store | AGENT_CHANNEL_STORE when bound, else in-process memory | Where message ids, sent messages, cards, questions in progress and relayed outcomes are kept (ChannelStore). |
durable | the app's WorkflowEngine when the agent runs durably | false → in this process; an engine → that one. See Durable processing. |
path | 'channels' | Routes are /<path>/<name> and /<path>/<name>/:token. false mounts none. |
Each channel:
| Option | Default | Meaning |
|---|---|---|
adapter | — | evolutionApi(…), whatsmiau(…), whatsappCloud(…), telegram(…) or your own ChannelAdapter. |
actor(message) | — | The sender's Actor, or null to not answer. This is the authentication of every message. |
thread(actor, message) | — | The thread to continue, or null/undefined for a new one. |
onThreadCreated(threadId, actor, message) | — | A new thread was created for the conversation. |
unknownSender, beforeTurn, canDeliver, onTurnStarted, onTurnEnded, renderComponent, mediaLimits, prepareMedia, transformInbound, formatOutcome, onWebhook | — | See Hooks. |
retry | 3 attempts, from 1 s | { attempts, backoffMs, maxBackoffMs, retryable(error) } per phase. |
allowRemember | true | false refuses "always in this conversation" in text decisions. |
uiCapabilities | { components: [] } | What the turn may draw (with renderComponent). |
pageContext | { kind: adapter.name } | An object, or a function of the message. channel: { name, conversation } is added to it. |
agentName | the thread's, else the default | The agent that answers. |
dedupeTtlMs | 24 h | How long a message id (and what was sent for it) is remembered. |
timeoutMs | 5 min | Stop waiting for a turn, cancel it and send what it produced. |
outcomeTimeoutMs | 60 s | How long to wait for an approved proposal to execute. |
questionTimeoutMs | 30 min | How long a question waits for its answer. |
texts | English, or Brazilian Portuguese with ptBrActionProposalText | An object, or a function of { actor, message, proposal }: approve, reject, proposal(p), footer, instruction(commands), linkInstruction, linkValidity(minutes), blockingApproval, failed, actionSucceeded, actionFailed, noPendingConfirmation, ambiguousDecision(ids, commands), rememberRefused, mediaRefused(reason, media, limits), questions, unknownSender, componentsOnly(components). |
onError(error, message) | logged | A message whose handling failed (after its retries). |
Each turn is sent with hostContext: { channel, conversation, messageId }, for a runner or engine that
reads it. A run that fails sends what it produced and texts.failed. A message that arrives while the
conversation's previous message is still being handled waits for it (one at a time per
conversation).
A controller of your own
path: false mounts no route; call the service from your controller instead — to put the webhook
under your own path, guards or rate limits:
import { AgentChannelsService } from '@dudousxd/nestjs-agent-channels';
import { All, Controller, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
@Controller('webhooks')
export class WebhooksController {
constructor(private readonly channels: AgentChannelsService) {}
@All('telegram')
telegram(@Req() req: Request, @Res() res: Response) {
return this.channels.handle('telegram', req, res); // Express or Fastify
}
}handleRequest(name, request) is the framework-free form: it takes a ChannelRequest (method,
url, header(name), params, body, rawBody) and returns { status, body, contentType? }.
The channel store and several replicas
The route keeps short-lived things: the provider message ids it took (a retried webhook starts one turn), the turn each message started, the messages it sent (one slot each), the cards sent to each conversation, the question a conversation is answering, and the outcomes it relayed. Each entry expires.
-
The SQL stores (the default with them).
DrizzleAgentStoreModuleandMikroOrmAgentStoreModulebindDrizzleChannelStore/MikroOrmChannelStoretoAGENT_CHANNEL_STORE, and the module picks it up: one row per key inagent_channel_state(keyprimary key,value,expires_at,created_at). A claim is a singleINSERT … ON CONFLICT DO NOTHING(INSERT IGNOREon MySQL), so the primary key is the lock across replicas; expired rows are deleted as claims come in (every 10 minutes by default,purgeEveryMs) and bypurgeExpired().ensureAgentSchemacreates the table, like the other agent tables; with migrations, generate one from the updated schema / entities. -
Redis —
RedisChannelStorefrom@dudousxd/nestjs-agent-transport-redistakes anioredisclient as is (SET … PX … NX,GET,DEL):Backend — api/src/channels/channels.module.ts import { RedisChannelStore } from '@dudousxd/nestjs-agent-transport-redis'; AgentChannelsModule.forRoot({ channels, store: new RedisChannelStore(redis) }); -
In memory otherwise (
InMemoryChannelStore) — one process only.
Anything implementing ChannelStore (claim, get, set, delete, with TTLs) works;
CHANNEL_STORE_CONTRACT from @dudousxd/nestjs-agent-testing checks one. Without a durable engine
the turn runs on the replica that took the webhook; with one, on whichever process the engine runs
the job. Its frames reach it through whatever
token sink is configured — a
shared one for a job resumed on another process.
Background work and deploys
Without a durable engine the route answers 200 before the turn runs, so a process that stops
mid-turn loses that answer (the provider will not retry an acknowledged webhook). On shutdown the
module waits for the messages it took (onModuleDestroy) — call app.enableShutdownHooks() so a
SIGTERM gets there. AgentChannelsService.drain() waits for them on demand, in tests too. With a
durable engine nothing is lost on a restart: the engine resumes the job
(Durable processing).
Writing an adapter
Any object of the ChannelAdapter shape works:
import type { ChannelAdapter } from '@dudousxd/nestjs-agent-channels';
export const sms: ChannelAdapter = {
name: 'sms', // the route, the dedupe prefix and the decision's `via`
capabilities: { markdown: 'none', maxLength: 1600 }, // `buttons: n` when it has reply buttons
verify: (request) => checkSignature(request.header('x-signature'), request.rawBody),
parse(body) {
const { MessageSid, From, Body } = body as Record<string, string>;
if (!MessageSid || !Body) return null;
return { id: MessageSid, from: From, conversation: From, text: Body, raw: body };
},
async send(conversation, message) {
// `message.buttons` only arrives when `capabilities.buttons` is 2 or more;
// `message.fallbackText` is the same message as text, for when the buttons are refused;
// `message.instruction` the reply instruction alone, for buttons that may not render;
// `message.footer` a card's footer line (already at the end of `fallbackText`);
// `message.media` a file (with `message.text` as its caption) — only with `capabilities.media`.
await smsApi.send(conversation, message.buttons ? message.fallbackText : message.text);
},
};verify(request)seesmethod,url(with the query string),header(name),params, the parsedbodyand therawBody(aBufferwithrawBody: true, elsenull). Compare secrets in constant time.parse(body)returns oneInboundMessage, an array, ornull.idmust be the provider's id for the message — retries carry the same one. A button press setsbuttonIdto the pressed button's id.challenge(request)(optional) answers a request that is not a message, such as a subscription check, beforeverify.acknowledge(message)(optional) runs in the background before the turn — a "typing…" presence (stop it in the channel'sonTurnEnded, called however the message's handling ends), or Telegram'sanswerCallbackQuery.parsemay put files onmessage.media({ kind, contentType?, filename?, sizeBytes?, ref });download(media, { maxBytes })(optional) fetches one and throwsChannelMediaTooLargeErrorpastmaxByteswithout reading the rest. Withoutdownload, media is answered withtexts.mediaRefused('disabled', …).send(conversation, message)throws when the provider refused.ChannelDeliveryError(exported) carries the status, anddefiniteis true for a400/403/404/422, when sending something else instead cannot duplicate the message.
Testing
AgentChannelsService.drain() resolves once every message the channels took has been handled (with a
durable engine: once their runs ended), so a test can post a webhook, drain, and look at what was
sent. Every built-in adapter takes a fetch option
for a fake:
const sent: unknown[] = [];
const adapter = telegram({
botToken: 'test',
secretToken: 'secret',
fetch: async (_url, init) => {
sent.push(JSON.parse(String(init?.body)));
return new Response('{"ok":true}');
},
});
// AgentChannelsModule.forRoot({ channels: [{ adapter, actor, thread, onThreadCreated }] }), then:
await request(app.getHttpServer())
.post('/channels/telegram')
.set('x-telegram-bot-api-secret-token', 'secret')
.send({ update_id: 1, message: { message_id: 1, chat: { id: 42, type: 'private' }, from: { id: 42 }, text: 'hi' } })
.expect(200);
await app.get(AgentChannelsService).drain();With a FakeModelProvider the whole flow runs without a model.
Action proposals
Independent approvals — an action waits as a proposal while the conversation goes on, is decided from the web, a text message, AG-UI or an operator console, and runs on a background worker.
Personal agents (Poppy)
Be the Company Agent of Personal Agent Protocol ("Poppy") conversations — personal agents talk to your agent on a user's behalf, signed out or within the account scopes the user granted.