Aviary
Guides

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/telegram

Backend only. There is no frontend of yours: the messaging app is the client.

For every webhook the route:

  1. Verifies it is the provider's (a shared token, a signature) — 401 otherwise.
  2. Parses the person's message; delivery receipts, the bot's own messages and groups are ignored.
  3. Deduplicates by the provider's message id, so a retried webhook starts one turn.
  4. Takes the message and answers 200 at once. With a durable engine the message is persisted as a run before the 200; 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.
  5. Runs the app's hooks: unknownSender for a sender actor() does not know, beforeTurn for the app's own flows (terms, onboarding, a quota).
  6. Attaches media — an image, a voice note, a document — after downloading it (prepareMedia may turn a voice note into text) and staging it in the attachment store, or answers why it cannot.
  7. Sends the message to the agent with text-only capabilities by default: every component arrives as its fallbackText (Rendering everywhere) — or as a file renderComponent makes 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.
  8. 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 canDeliver first.
  9. Sends a question the turn asks (the ask tool, an intake) as text with numbered options, and reads the person's next messages as the answers.
  10. 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-channels

It 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):

Backend — api/src/main.ts
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):

Backend — api/src/main.ts
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
app.useBodyParser('json', { limit: '25mb' });

The agent

Backend — api/src/app.module.ts
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.

Backend — api/src/channels/channels.module.ts
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().

Backend — api/.env
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: false turns them off). Since they render, the buttons card carries no text instruction — just the summary and Confirmar/Cancelar (typing sim/não still works). A 4xx falls back to the text message, instruction included, and so does buttons: 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}), so url is the host alone; a url that already ends in a version (/v1, or the hosted service's https://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).

Backend — api/src/channels/channels.module.ts
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

Backend — api/src/channels/channels.module.ts
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 / whatsmiauwhatsappCloudtelegram
Verificationshared token (query, url segment or header)X-Hub-Signature-256 HMAC of the raw body; GET hub.challengeX-Telegram-Bot-Api-Secret-Token
MarkdownWhatsApp (*bold*, _italic_, ~strike~, ```)WhatsAppMarkdownV2, or none
Message length4096 (maxLength)40964096
Proposal buttonsoff 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
Inboundtext, extended text, button and list replies, mediatext, interactive button/list replies, template quick replies, mediatext, callback_query, media
Media downloadinline base64, mediaUrl, or getBase64FromMediaMessageGraph API media id → urlgetFile (≤ 20 MB)
Ignoredown messages, broadcasts, groups (unless groups)other numbers, statusesbots, 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 — Confirm and Cancel (texts.approve / texts.reject), whose ids are agora:approve:<ref> / agora:reject:<ref> (ref is the proposal id's last 32 characters, so it fits Telegram's 64-byte callback_data). A press is matched against the thread's proposals (AgentService.listActionProposals) and decided through AgentService.decideActionProposal — the same tenant-scoped, policy-checked decision the web's approve/reject routes make — recorded with via set to the adapter's name ('whatsapp', 'telegram'). The reply is the configured actionProposalText.replies text.
  • Without buttons — the confirmation plus texts.instruction({ approve, reject }), where approve/reject are the first words of the configured vocabulary (yes/no, or sim/não with ptBrActionProposalText), 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.

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 #ID naming 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: let audio/ogg through 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 are ptBrChannelQuestionTexts).

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 the agora.channel.job workflow 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 answers 500, 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) and read (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, an HttpException or a provider 4xx, is not retried). What still fails goes to onError.
  • 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.timer run 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:

HookWhenReturns
unknownSender(message, context)actor() mapped the sender to nobodyreplies 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 outcomesfalse 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 functionper 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 drewa file (a chart as an image), text, several, or nothing for its fallbackText.
mediaLimits, prepareMedia, transformInboundan inbound file / the message going to the agentsee Media.
formatOutcome(proposal, { text, texts })an executed proposalthe 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 URLby bytes
evolutionApi / whatsmiauPOST /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
telegramsendPhoto / sendDocument / sendAudio / sendVideo with the URLthe 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? }):

OptionDefaultMeaning
channels—One entry per channel (below). Adapter names must be unique: the name is the route and the decision's via.
storeAGENT_CHANNEL_STORE when bound, else in-process memoryWhere message ids, sent messages, cards, questions in progress and relayed outcomes are kept (ChannelStore).
durablethe app's WorkflowEngine when the agent runs durablyfalse → 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:

OptionDefaultMeaning
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.
retry3 attempts, from 1 s{ attempts, backoffMs, maxBackoffMs, retryable(error) } per phase.
allowRemembertruefalse 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.
agentNamethe thread's, else the defaultThe agent that answers.
dedupeTtlMs24 hHow long a message id (and what was sent for it) is remembered.
timeoutMs5 minStop waiting for a turn, cancel it and send what it produced.
outcomeTimeoutMs60 sHow long to wait for an approved proposal to execute.
questionTimeoutMs30 minHow long a question waits for its answer.
textsEnglish, or Brazilian Portuguese with ptBrActionProposalTextAn 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)loggedA 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:

Backend — api/src/channels/webhooks.controller.ts
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). DrizzleAgentStoreModule and MikroOrmAgentStoreModule bind DrizzleChannelStore / MikroOrmChannelStore to AGENT_CHANNEL_STORE, and the module picks it up: one row per key in agent_channel_state (key primary key, value, expires_at, created_at). A claim is a single INSERT … ON CONFLICT DO NOTHING (INSERT IGNORE on 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 by purgeExpired(). ensureAgentSchema creates the table, like the other agent tables; with migrations, generate one from the updated schema / entities.

  • Redis — RedisChannelStore from @dudousxd/nestjs-agent-transport-redis takes an ioredis client 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:

Backend — api/src/channels/sms.adapter.ts
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) sees method, url (with the query string), header(name), params, the parsed body and the rawBody (a Buffer with rawBody: true, else null). Compare secrets in constant time.
  • parse(body) returns one InboundMessage, an array, or null. id must be the provider's id for the message — retries carry the same one. A button press sets buttonId to the pressed button's id.
  • challenge(request) (optional) answers a request that is not a message, such as a subscription check, before verify.
  • acknowledge(message) (optional) runs in the background before the turn — a "typing…" presence (stop it in the channel's onTurnEnded, called however the message's handling ends), or Telegram's answerCallbackQuery.
  • parse may put files on message.media ({ kind, contentType?, filename?, sizeBytes?, ref }); download(media, { maxBytes }) (optional) fetches one and throws ChannelMediaTooLargeError past maxBytes without reading the rest. Without download, media is answered with texts.mediaRefused('disabled', …).
  • send(conversation, message) throws when the provider refused. ChannelDeliveryError (exported) carries the status, and definite is true for a 400/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:

Backend — api/test/telegram-webhook.e2e-spec.ts
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.

On this page