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.
@adonis-agora/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 get back a route handler:
router.post('/webhooks/whatsapp', channels.handle(evolutionApi({ … }), { actor, thread }))For every webhook the handler:
- 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(Surfaces) — 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 (tables become lists, or what
renderTablemakes of them), splits it at the channel's length limit (on paragraph, line or sentence boundaries) and sends it — every message checked bycanDeliverfirst. - 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 handler 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.
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().
Environment
export default await Env.create(new URL('../', import.meta.url), {
// …
EVOLUTION_URL: Env.schema.string(),
EVOLUTION_INSTANCE: Env.schema.string(),
EVOLUTION_API_KEY: Env.schema.string(),
// a long random string — the only proof a webhook came from your Evolution server
EVOLUTION_WEBHOOK_TOKEN: Env.schema.string(),
})EVOLUTION_URL=https://evolution.example.com
EVOLUTION_INSTANCE=support
EVOLUTION_API_KEY=…
EVOLUTION_WEBHOOK_TOKEN=…Agent configuration
import { defineConfig, ptBrActionProposalText } from '@adonis-agora/agent'
export default defineConfig({
// …
actionApprovalMode: 'independent',
backgroundActorResolver: {
resolve: async ({ 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,
})Who is writing, and which thread
The sender is a phone number. Map it to one of your accounts — only numbers someone verified (a code sent to that number, say) — and keep one thread per conversation:
import { channels, evolutionApi } from '@adonis-agora/agent/channels'
import env from '#start/env'
import User from '#models/user'
import ChannelThread from '#models/channel_thread'
export const whatsapp = evolutionApi({
url: env.get('EVOLUTION_URL'),
instance: env.get('EVOLUTION_INSTANCE'),
apiKey: env.get('EVOLUTION_API_KEY'),
webhookToken: env.get('EVOLUTION_WEBHOOK_TOKEN'),
})
export const whatsappWebhook = channels.handle(whatsapp, {
// `from` is the sender's number in international format, digits only: 5511999990000
async actor(message) {
const user = await User.query().where('whatsapp_phone', message.from).whereNotNull('whatsapp_verified_at').first()
return user ? { id: String(user.id), roles: user.roles } : null
},
async thread(actor, message) {
const row = await ChannelThread.query()
.where({ channel: 'whatsapp', conversation: message.conversation, actorRef: actor.id })
.first()
return row?.threadId ?? null // null → a new thread, reported below
},
async onThreadCreated(threadId, actor, message) {
await ChannelThread.create({ channel: 'whatsapp', conversation: message.conversation, actorRef: actor.id, threadId })
},
// with `actionProposalText: ptBrActionProposalText`, every other text is already in Portuguese
texts: {
unknownSender: 'Vincule seu WhatsApp na sua conta para conversar comigo.',
},
})import router from '@adonisjs/core/services/router'
import { whatsappWebhook } from '#channels/whatsapp'
router.post('/webhooks/whatsapp', whatsappWebhook)(#channels/* is an import alias — add "#channels/*": "./app/channels/*.js" to package.json's imports, or import the file by path.)
Webhooks carry no CSRF token: exclude the route from Shield's CSRF check.
csrf: {
enabled: true,
exceptRoutes: (ctx) => ctx.request.url().startsWith('/webhooks/'),
// …
},Pointing Evolution at the route
Set the instance's webhook (in the manager, or POST {EVOLUTION_URL}/webhook/set/{instance}) to
https://app.example.com/webhooks/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 a :token route param, an Authorization: Bearer header or an x-webhook-token header if your setup sends one. 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 request's logger with the event and the reason, never the content — at warn when it looked like a person's message that could not be read (no fromMe and another status), at 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, or a card's own confirm.verb) 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 '@adonis-agora/agent/channels'
import env from '#start/env'
export const whatsapp = whatsmiau({
url: env.get('WHATSMIAU_URL'), // http://whatsmiau:8080 → http://whatsmiau:8080/v1/…
instance: env.get('WHATSMIAU_INSTANCE'),
apiKey: env.get('WHATSMIAU_API_KEY'),
webhookToken: env.get('WHATSMIAU_WEBHOOK_TOKEN'),
})WhatsApp Cloud API
Meta's Cloud API signs every webhook and renders reply buttons (up to 3).
import { channels, whatsappCloud } from '@adonis-agora/agent/channels'
import env from '#start/env'
export const whatsappCloudWebhook = channels.handle(
whatsappCloud({
phoneNumberId: env.get('WHATSAPP_PHONE_NUMBER_ID'),
accessToken: env.get('WHATSAPP_ACCESS_TOKEN'),
appSecret: env.get('WHATSAPP_APP_SECRET'), // X-Hub-Signature-256 is an HMAC under it
verifyToken: env.get('WHATSAPP_VERIFY_TOKEN'), // what you type as "Verify token" in the app
}),
{ actor, thread, onThreadCreated }, // as above
)router.get('/webhooks/whatsapp-cloud', whatsappCloudWebhook) // the hub.challenge subscription check
router.post('/webhooks/whatsapp-cloud', whatsappCloudWebhook)Subscribe the app to the messages field. The signature is computed over the raw body, which Adonis's body parser keeps for JSON requests (request.raw()); a request without it is refused. 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 handler only answers people who just wrote, so it stays inside it, but an outcome relayed much later may need a template.
Telegram
import { channels, telegram } from '@adonis-agora/agent/channels'
import env from '#start/env'
export const telegramWebhook = channels.handle(
telegram({
botToken: env.get('TELEGRAM_BOT_TOKEN'),
secretToken: env.get('TELEGRAM_WEBHOOK_SECRET'), // [A-Za-z0-9_-], 1–256 characters
}),
{
actor: async (message) => findTelegramUser(message.from), // from = the Telegram user id
thread,
onThreadCreated,
},
)router.post('/webhooks/telegram', telegramWebhook)Register the webhook once:
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
-H 'content-type: application/json' \
-d '{"url":"https://app.example.com/webhooks/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, route param 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.
Proposals: buttons and text
When a turn leaves a proposal pending, the handler sends its confirmation (the tool's presentation.confirm title and detail; a tool without one reads as its presentation.label, else its name in words — "Refund order?", never refund_order) after the reply:
- With buttons — the tool's
confirm.verb("Refund";texts.approve,Confirm, when it declares none) andCancel(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 and decided throughAgentService.decideActionProposal— the same scoped, policy-checked decision a text decision makes — 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.
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 handler 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 handler 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.
channels.handle(whatsapp, {
// …
formatOutcome(proposal, { text }) {
const forward = (proposal.execution?.result as { forward?: string } | undefined)?.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 handler's wait and channels.onSettled 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 by channels.onSettled, the worker's settle hook:
import { channels } from '@adonis-agora/agent/channels'
export default defineConfig({
// …
actionApprovalMode: 'independent',
actionProposalWorker: { onSettled: channels.onSettled },
})Every turn the handler starts carries pageContext.channel = { name, conversation } (merged into your pageContext), and a proposal records its turn's page context. channels.onSettled(proposal) reads it, finds the adapter channels.handle() registered under that name, and sends the outcome text there. Proposals from other surfaces (no channel) and channels this process never registered are ignored, so the hook is safe next to a web chat. The handler's own wait and the hook claim the same key in the channel's store, so an outcome is sent once even when both see it — across replicas too, with a shared store.
Adapter names must be unique per process (two WhatsApp adapters need name: 'whatsapp-sales' and name: 'whatsapp-support'). The adapters are registered when channels.handle() runs — in start/routes.ts, which the web process that runs the worker loads. channels.deliverOutcome(proposal) sends one by hand (true when it sent), channels.adapter(name) returns a registered adapter.
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 handler checks the file's type and declared size against the attachment store's limits (its describe(), else 20 MB and images, PDF, plain text, CSV), downloads it through the adapter — never past the size limit — stages it with AgentService.stageAttachment(actor, file) and sends the message (the caption, or nothing) with attachments: [{ mediaId }]. Configure an attachment store for this:
import { defineConfig, attachmentStores } from '@adonis-agora/agent'
export default defineConfig({
// …
attachments: attachmentStores.media(), // any Drive disk, through @adonis-agora/media
})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.
channels.handle(whatsapp, {
// …
mediaLimits: (limits, media) =>
media.kind === 'audio' && limits
? { maxBytes: Math.max(limits.maxBytes, 16 * 1024 * 1024), allowedContentTypes: [...limits.allowedContentTypes, 'audio/ogg'] }
: limits,
prepareMedia: async (file, media) =>
media.kind === 'audio' ? { text: await 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 handler 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 handler 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, and the agent proceeds on its own assumptions. The question in progress lives in the channel's 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'), the handler 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 { channels, ptBrChannelTexts } from '@adonis-agora/agent/channels'
channels.handle(whatsapp, {
// …
texts: { ...ptBrChannelTexts, actionSucceeded: 'Feito! ✅' },
})Durable processing
When the agent runs on @adonis-agora/durable (durable: true in config/agent.ts), the channels run on the same engine — nothing to configure (durable: false opts out; durable: engine names one):
- 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, such as aQuotaBlockedErroror a provider4xx, is not retried). What still fails goes toonError. - Phases dispatched to any process. Each phase is a dispatched step —
agora.channel.prepare,agora.channel.act,agora.channel.read(andagora.channel.gather,agora.channel.timeout) — served on the durable engine's transport by every process that created the channel's handler. Any of them runs a phase, and any of them continues the job: nothing is pinned to the process that took the webhook or started the workflow. A phase's input is plain JSON (the job and what the phases before it returned), so it is rebuilt wherever it lands. See Where the phases run. - 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) 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.
The recovering process must know the channel: create the handlers at boot, in every process that runs durable work (a preload file, or start/routes.ts when the web process runs the engine) — not lazily on the first webhook. A process that has not registered a channel leaves its 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).
Where the phases run
With the agent's engine (durable: true in config/agent.ts) the phases are served on the engine's transport (@adonis-agora/durable's container binding — the in-memory one when none is configured, else BullMQ, the database…). With an engine you pass (durable: engine), hand its transport too: durableSteps: transport (or give the engine a handleStep(name, fn)). With neither — or durableSteps: false — each phase is a ctx.localStep in the process that runs the workflow, as before.
Each dispatched phase carries a liveness timeout, so a step whose job was lost (a worker gone, a broker that dropped it) is dispatched once more and then fails the job instead of waiting forever: read gets timeoutMs + outcomeTimeoutMs + 2 minutes, act 15 minutes (downloads, prepareMedia, the turn's start), prepare 10 minutes, gather and a question's timeout 2 minutes. A phase heartbeats while it runs, so on a transport that carries heartbeats the window is silence, not duration. A phase that fails comes back to onError as the engine's step error (its message is the original one).
The channel workflows are at version 2. Version 1 — every phase a local step, no bursts — stays registered, so a run started before an upgrade finishes on the path it was recorded on.
Without a durable engine the same pipeline runs in the web process 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). await handler.drain() from a shutdown hook waits for what is in flight.
Bursts: several messages, one turn
People write in bursts — "oi" / "tudo bem?" / "queria saber X", or ten PDFs in a row — and by default every message is a turn of its own, one at a time: the tenth file is answered minutes after the first. coalesce makes a burst ONE turn:
channels.handle(whatsapp, {
// …
coalesce: { quietMs: 3000 }, // maxWaitMs: 30_000, maxMessages: 20 by default
})- A message waits until its conversation has been quiet for
quietMs— at mostmaxWaitMsfrom the first message being taken, at mostmaxMessagesmessages. Then the burst is one turn: the texts joined in order (a blank line between them), every file attached. Each message's files go throughmediaLimits/prepareMedia/ refusals exactly as if it were alone — a refused file is answered and the rest still make the turn. - Control messages are never grouped: a button press (ours or a component's), a text decision (
yes,no #ID, in the configured vocabulary) and every message while a question waits for its answer are handled on their own, in order — and one ends the burst before it.when(message)keeps more out (when: (m) => (m.media?.length ?? 0) > 0groups only files). - Messages that arrive while a grouped turn runs go to the next turn (the conversation is still one at a time).
- Durable and multi-instance. With a durable engine the wait is the engine's durable sleep — the leading message's run is
suspendedwith a wake time, visible in the durable dashboard, and resumed by whichever process polls timers first (wake-up granularity: the worker's poll interval, 1 s by default). Inline, it is a timer in the web process. - Exactly once. The webhook still answers
200only after the message is persisted (and recorded in the conversation's burst log in the channel store); dedupe by message id is unchanged. Every message keeps its own job: the first to run leads the burst and claims the others (one owner key per message, in the channel store); a claimed message's own job ends without a turn once the leader's turn started. A leader that fails before its turn leaves the others to their own jobs — a message is never dropped and never answered twice, through retries, recoveries and redelivered webhooks.
What hooks see for a grouped turn — every field below is set only when coalesce grouped several messages (or, for the numbers, when coalesce is on), so a channel without coalesce gets exactly what it always did:
| Hook | What it gets |
|---|---|
actor, unknownSender, beforeTurn | called per message, as if each were alone (their replies keyed by that message); context.coalesced lists the whole burst. A message beforeTurn stops is left out of the turn; the others go on. |
prepareMedia | per file, with its own message, and context.coalesced. |
transformInbound(inbound, context) | once: inbound.text / inbound.attachments merged, inbound.parts = [{ message, text, attachments }] per message (to put each caption next to its file), context.message the first message, context.coalesced all of them. |
onTurnStarted / onTurnEnded | once for the turn: message = the first message, coalesced = all of them, attachments = how many files it was sent with, queueWaitMs = from the webhook taking the first message to the turn starting. A message grouped into another's turn has no onTurnEnded of its own. |
AgentService.send | hostContext.messageIds lists every message (messageId is the first); sentAt is the first message's send time. |
When a message was sent
Every adapter reads the provider's own timestamp into InboundMessage.sentAt (epoch ms): Evolution / Whatsmiau messageTimestamp, WhatsApp Cloud timestamp, Telegram date (a Telegram button press carries none). The channel hands it to the turn (AgentService.send({ sentAt })) and it is stored with the person's message as StoredMessage.sentAt — so a message that waited (in the queue behind a running turn, in a burst) still knows when it was said. A grouped turn takes its first message's time; each message's own is on inbound.parts[i].message.sentAt for a transformInbound that wants to write them into the text.
Nothing reads it unless you ask: with messageTimestamps in config/agent.ts the model sees every user message labelled with when it was sent ([sent 2026-10-23T11:17-03:00] começou a mamada agora), so "now" in a message means its send time — not the moment a queued turn, or a later one, reads it. The stored text and what the UI shows stay as the person wrote them.
Hooks
Generic hooks for what an app does around the conversation — each optional:
| 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, coalesced?, queueWaitMs? }) | a message (or a burst) started (or queued) a turn | — record its channel, start a meter. |
onTurnEnded({ channel, conversation, actor, message, runId, threadId, outcome, error, coalesced?, attachments?, queueWaitMs? }) | always, once this process is done with a message (or with a run resumed after its question), 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. |
renderTable(table, context) | a table (GitHub markdown) in the reply text | what goes in its place — the table as an image, say — or nothing to keep it as the list the channel's markdown makes. The text around it goes before and after. |
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 Outcomes. |
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.
channels.handle(whatsapp, {
actor: (message) => accountActor(message.from), // null → unknownSender
unknownSender: async (message) =>
(await isLead(message.from)) ? await onboardingStep(message) : `Link your number: ${linkUrl()}`,
beforeTurn: async (message, actor) =>
(await termsAccepted(actor)) ? 'continue' : { reply: termsText(actor) },
canDeliver: async ({ conversation, actor, actorRef }) =>
(await accountActor(phoneOf(conversation)))?.id === (actor?.id ?? actorRef),
texts: ({ actor }) => textsIn(localeOf(actor)),
onTurnStarted: ({ runId }) => recordRunChannel(runId, 'whatsapp'),
onTurnEnded: ({ conversation }) => stopTyping(conversation), // started in the adapter's acknowledge
onWebhook: ({ status, reason }) => recordWebhookOutcome(status, reason),
// …
})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:
channels.handle(whatsapp, {
// …
uiCapabilities: { components: [{ name: 'GrowthChart', versions: [1] }] },
renderComponent: async (component, { rendered }) =>
rendered < 3 && drawable(component.name)
? { media: { kind: 'image', url: await chartImageUrl(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
channels.handle(adapter, options):
| Option | Default | Meaning |
|---|---|---|
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, renderComponent, mediaLimits, prepareMedia, transformInbound, formatOutcome, onWebhook | — | See Hooks. |
durable | the agent's engine when it runs durably | false → in this process; an engine → that one. See Durable processing. |
durableSteps | the agent engine's transport | Where the phases run as dispatched steps (anything with handle(name, fn)); false → local steps. See Where the phases run. |
coalesce | off | { quietMs, maxWaitMs?, maxMessages?, when? } — a burst of messages as one turn. See Bursts. |
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. |
service | AgentService from the container | Anything with the same send/subscribe/… methods (ChannelTurnService). |
store | lucidChannelStore() when the agent store is Lucid, else in-process memory | Where message ids, sent messages, cards, questions in progress and relayed outcomes are kept (ChannelStore). |
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), blockingApproval, failed, actionSucceeded, actionFailed, noPendingConfirmation, ambiguousDecision(ids, commands), rememberRefused, mediaRefused(reason, media, limits), questions, unknownSender, componentsOnly(components). |
onError(error, message) | console.error | A message whose handling failed (after its retries). |
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).
The channel store and several replicas
The handler 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.
- Lucid store (the default with
stores.lucid()). When the agent's own store is Lucid, the handler useslucidChannelStore()over the same database: one row per key inagent_channel_state(keyprimary key,value,expires_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. The table is created on first use and bycreateAgentTables/ the published migration, like the other agent tables. Chosen because an app on the Lucid store already has a database every replica shares, and needs nothing else. - In memory otherwise — one process only.
- Redis, or another connection:
import redis from '@adonisjs/redis/services/main'
import db from '@adonisjs/lucid/services/db'
import { channels, lucidChannelStore, redisChannelStore } from '@adonis-agora/agent/channels'
channels.handle(adapter, { …, store: redisChannelStore(redis) }) // SET … PX … NX, GET, DEL
channels.handle(adapter, { …, store: lucidChannelStore(db, { connection: 'pg', table: 'wa_state' }) })lucidChannelStore(db, { connection?, table?, autoCreateTables?, purgeEveryMs? }); anything implementing ChannelStore (claim, get, set, delete, with TTLs) works. 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 (Redis, Lucid) for a job resumed on another process.
Background work and deploys
Without a durable engine the handler answers 200 before the turn runs, so a process that stops mid-turn loses that answer (the provider will not retry an acknowledged webhook). await handler.drain() waits for every message the handler took — call it from a shutdown hook, and in tests. 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 '@adonis-agora/agent/channels'
export const sms: ChannelAdapter = {
name: 'sms', // 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 therawBodystring. 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. SetsentAt(epoch ms) from the provider's send timestamp when it has one — see When a message was sent.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 — Telegram'sanswerCallbackQuery, or a "typing…" presence (stop it in the handler'sonTurnEnded, which is called however the message's handling ends).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', …).handler.handleRequest(request)runs the same flow for aChannelRequestbuilt by hand, answering{ status, body }.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
handler.drain() resolves once every message the handler 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}')
},
})
const webhook = channels.handle(adapter, { actor, thread, onThreadCreated })
// … route it, post an update with the secret header, then:
await webhook.drain()With a FakeModelProvider the whole flow runs without a model.
Components on a channel
By default a channel gets each component as its text summary. With genui({ channels: { whatsapp: { … } } }) the turn is offered only the tools WhatsApp can draw, and components arrive natively — reply buttons, list messages, chart images — with a pressed button coming back as the next turn. See Generative UI per channel.
One output, many surfaces
How one tool result reaches a React chat, an AG-UI client, a text-only channel such as WhatsApp, and an MCP client — what lives on the AdonisJS backend and what in the Inertia React frontend, UI capability negotiation, text fallbacks, and what AG-UI carries compared with the native stream.
Authorization
Who is calling and what they may do — the ActorResolver identity seam (anonymous per-browser by default, one line to require login), the DefaultToolAuthorizer role intersection, and the offered-and-invoke double check.