Day 32

Multi-channel chat: web, SMS, WhatsApp, iMessage and Slack

You ship
WorkBot's assistant on 4 channels (web, SMS, WhatsApp and Slack) with shared conversations, delivery callbacks and retries.

What you'll build today

After Day 31, WorkBot reads every document format customers send, but its assistant answers only in the web chat. Today you put the same assistant on SMS, WhatsApp and Slack, so people can reach it where they already write. One store of conversations on the server sits behind all four channels.

You learn the adapter pattern, where each channel turns what arrives into one shape and renders what goes back. You also learn to link two identities only by proof, to answer webhooks fast and safely, and to respect WhatsApp's rules for when a business may write. Any channel can hand a conversation to a person.

By the end, a customer can start on the web and continue on WhatsApp in the same conversation. Without a Twilio account or a Slack workspace, no message leaves your machine, so the last part says what an account adds.

Channels
Each channel's adapter hands one message to the same turn, the code that produces one reply. The turn reads and writes one store of conversations and the identity links. Text replies go out through a workflow and an outbox, and a person gets a hand-off with the transcript.

By the end of the day you can

  • Run one agent across several messaging channels with shared session state
  • Implement webhook channels (Twilio SMS and WhatsApp, iMessage providers) with delivery and retry handling
  • Ship the same bot to Slack and other workplace chat with the Vercel Chat SDK

Topics

One Agent, Many Surfaces

  • Channel adapter pattern: normalize each inbound message to one shape, { channel, address, text, thread }, pass it to the agent, and render the reply per channel. Attachments are not handled yet
  • Identity across channels: link the same person on web and WhatsApp by verification, never by guessing
  • Channel constraints: length limits, no markdown on SMS, limited rich elements on WhatsApp, threads on Slack
  • Latency expectations: the web streams and SMS waits. Show typing indicators where the channel supports them

Webhook Channels: Twilio SMS + WhatsApp

  • Inbound: Twilio posts to your webhook. Validate the signature, respond fast and process asynchronously (Day 18)
  • Outbound: the Messaging API, status callbacks (queued, sent, delivered, failed), and retries with idempotency keys, so a retry never sends a message twice
  • WhatsApp rules: the 24-hour customer service window, approved templates for messages the business starts, and opt-in
  • Meta's WhatsApp Cloud API: the alternative to Twilio, with the same window and template rules

iMessage

  • Personal iMessage has no public API. Business messaging goes through Apple Messages for Business and approved providers
  • Provider-backed iMessage: webhooks in, an API out, and delivery receipts. Fall back to SMS when the recipient is not on iMessage
  • Compliance: consent before the first message, STOP handling, and quiet hours

Workplace Chat with the Vercel Chat SDK

  • One codebase, many platforms: adapters for Slack, Microsoft Teams, Google Chat, Discord and Telegram behind one API
  • Primitives: threads, mentions, reactions, and ephemeral messages that only one person sees
  • Serverless operation: event delivery, retries and deduplication

Tool Calls + Generative UI per Channel

  • Tools run the same everywhere, and only the rendering differs: cards on Slack, plain text on SMS, interactive components on the web only
  • Human handoff: escalate a thread to a person on any channel, with the transcript

Build

Features shipped today

  • WorkBot: Slack for employees, and WhatsApp and SMS for customers alongside the web app, with one session store. iMessage is documented, with SMS as its fallback. Email intake stays on the Day 18 workflow

Put your agent on 4 channels with one session store:

  1. Build the channel adapter and the session store (Postgres), with a channels table that links verified identities
  2. SMS and WhatsApp via Twilio: an inbound webhook with signature validation, asynchronous processing, and outbound messages with status callbacks and retries
  3. Slack via the Vercel Chat SDK: a mention starts a thread, replies continue it, and tool results render as cards
  4. iMessage via a provider sandbox where one is available. Otherwise, document the integration and ship the SMS fallback
  5. Test that a conversation started on the web and continued on WhatsApp keeps its context. Test that a failed outbound message retries and reports. Test that WhatsApp's template rules hold outside the 24-hour window

Step by step

One turn for every channel

Every channel needs the same answer from the assistant, so start by taking the web chat's turn out of its route. A turn is the code that takes one message and produces one reply.

Study first

  • In the adapter pattern, each channel's adapter turns what arrives into one shape, { channel, address, text, thread }, and renders what goes back. The agent in the middle does not know which channel it is on, except for a line about how to write. Enterprise Integration Patterns describes the same idea as a normalizer, with one translator per incoming format and one common message out.
  • Where a conversation lives decides where it can continue, so a history kept in the browser cannot be continued from a phone.
  • Latency differs by channel. The web streams and SMS waits for the whole reply, so the same turn serves both and only the call that runs it differs. The AI SDK's guide to generating text shows both calls, streamText for the web and generateText for a channel that waits.

Build

  1. Move the web chat's turn out of its route, into prepare in src/lib/assistant.ts. It puts the versioned prompt from Day 19 first, so the provider can cache it. Then it adds what is known about the person, and the conversation trimmed to the context budget from Day 10. The web streams the turn, and the other channels wait for it.
  2. Add a conversations table and src/lib/history.ts. Each conversation has its person and its messages, and each message is marked with the channel it came by.
  3. Make the web route, POST /api/chat, start from the server's copy when there is one, so turns taken on another channel are part of the next answer. Update the copy after each reply. When the chat page opens a conversation, it fetches the server's copy and labels each message that came by another channel.

Done when

  • A conversation that includes WhatsApp turns reaches the model whole, even when the browser sends only its own older messages and the new question.
  • A turn whose save has not landed yet is kept from the browser's copy.
Technical notes
  • Guard the save in SQL, so an update applies only where the stored person matches. A request that carries someone else's conversation id then cannot overwrite it.
  • Taking the server's copy as the conversation is safe, because the web chat retries only after a failed turn and a failed turn saves nothing.

One person across channels

With conversations on the server, the next question is whose conversation a new message belongs to. A matching phone number or name is not enough to decide that.

Study first

  • Link identities across channels by verification, never by guessing. A matching phone number or name is a claim, while holding both accounts is proof.
  • A one-time code provides that proof. It is shown in one channel and sent from the other, and it is short-lived, single-use and stored as its hash. The US federal digital identity guideline sets the same limits for a code sent through a second channel, valid for at most 10 minutes and accepted only once.

Build

  1. Write linkCode(person, conversation) in src/lib/channels.ts. It makes a code of 8 characters from an alphabet without look-alikes (no 0, O, 1 or I), valid for 10 minutes. Store it as a SHA-256 hash, a one-way fingerprint of the code.
  2. Add a channels table. It records each sender on each channel, the person behind it, the conversation it continues, when it last wrote and whether it opted out. A sender who never linked is a person of their own, such as whatsapp:+15105550142.
  3. Handle link <code> sent from a phone or a Slack account, which links that address to the person and their conversation. A linked WorkBot user gets the tools of their role (Day 30), and anyone else gets the tools for customers.
  4. Add "Continue on your phone" to the chat page. It shows the code, with links that open WhatsApp or the SMS app with the message typed in. Employees link Slack from their settings page.

Done when

  • A code links once and then expires, and a wrong code links nothing.
  • A number that never sent a code keeps a conversation of its own.
A code to continue on a phone
The web chat offers a one-time code. Sending it from WhatsApp or by SMS carries this conversation on there.

SMS and WhatsApp through Twilio

SMS and WhatsApp both reach WorkBot through Twilio's webhooks. A model's reply takes longer than a webhook may wait, so the webhook only checks the message and hands the work to a workflow.

Study first

  • Twilio signs each webhook request. For a form-encoded request it computes an HMAC-SHA1, a hash-based message authentication code, keyed with the account's auth token. It signs the exact URL it called, followed by each parameter, name then value, sorted by name. Twilio's SDKs ship a validator, and the check is a few lines either way.
  • Respond fast and process asynchronously (Day 18), because Twilio needs an answer within seconds and a model's turn takes longer.
  • The Messaging API sends outbound messages, and status callbacks report each one as queued, sent, delivered, undelivered or failed. A retry must not resend a message the provider accepted.
  • WhatsApp limits when a business may write. A customer's message opens a 24-hour window for free-form replies, and outside it a business can send only an approved template. A business also needs the customer's opt-in before it writes first, and every channel must honour STOP.

Build

  1. Write src/lib/twilio.ts. Check the signature over the public URL set in APP_URL, because behind a proxy the server sees a different one. Add one function that sends through the Messaging API with a status callback.
  2. Add /api/webhooks/twilio. It checks the signature, then hands the message to Inngest (Day 18) under Twilio's message id, so a redelivery is the same run. It answers at once with empty TwiML, Twilio's XML reply format.
  3. Write the workflow src/inngest/channel-reply.ts. Its steps are the turn, the reply queued in the outbound table, the send, and the memory extracted from the exchange (Day 10).
  4. Write deliver in src/lib/outbound.ts. It never sends again a message that already has the provider's id. It records a 429 or a 5xx and retries, while anything else fails for good with the provider's code.
  5. On WhatsApp outside the window, make deliver send the approved template or fail with that reason. In the reference run the template has no variables. Add /api/webhooks/twilio/status, which records Twilio's word on each message.
  6. Make STOP, UNSUBSCRIBE, CANCEL, END and QUIT silence an address until it sends START.

Done when

  • A signed message reaches the workflow, and an unsigned or altered one does nothing.
  • A send whose id is stored is not repeated, and a provider failure retries and is reported.
  • WhatsApp's window holds in both directions.
Technical notes
  • Test the send against a stand-in for the Messaging API on your machine. WorkBot reaches it through TWILIO_API_URL, the same setting that points WorkBot at a regional Twilio host. The stand-in answers each send with the next scripted response, such as a message id, a 503 or an invalid number.
  • A send can crash after Twilio accepts it but before the id is stored, and a retry would then send it again. Storing the id is the next statement after the call, which keeps that window small but does not close it.
  • Without an account, the send fails at once with "no Twilio account is configured", and the Channels page, /admin/channels, shows that against each reply.
  • STOP silences the address it came from, not every channel of a linked person. Nothing collects WhatsApp opt-in, so WorkBot only answers people who wrote first, inside their window.

Slack through the Chat SDK

Employees reach WorkBot in Slack. The Vercel Chat SDK handles Slack's signatures, deadlines and redeliveries, and the same code could later serve other workplace chats.

Study first

  • The Chat SDK gives one codebase many platforms. It has adapters for Slack, Teams, Google Chat, Discord and others, behind one API of threads, mentions, posts and cards.
  • Slack expects an answer within 3 seconds and redelivers when it does not get one. Its guide to the Events API explains the three retries and the header that numbers each one. So on serverless the handler runs after the response, and the SDK drops duplicate deliveries.
  • The SDK keeps state between requests, such as the threads it follows, a lock per thread, deduplication keys and queues. The SDK's page on state adapters lists what that state holds, and names the interface your own adapter implements.

Build

  1. Write src/lib/chat-state.ts, which implements the SDK's state interface over one WorkBot table, with each operation as one statement.
  2. Write src/lib/bot.ts. A mention starts a thread the bot follows, and each message in it continues the conversation. The thread is the conversation, and the author's linked account sets the role. A reply that used tools goes as a card that names them.
  3. Serve /api/webhooks/slack through the SDK. Put a Slack app manifest, the file that describes the app to Slack, in docs/channels.md.

Done when

  • The route answers Slack's URL check and refuses an unsigned event.
  • A mention in a thread is answered in that thread, with the conversation kept.
Technical notes
  • The SDK's own Postgres adapter connects with its own driver and creates its own tables, outside the row-level security of Day 30. Write the adapter over WorkBot's database instead. The bot's state is then separated by tenant, the way WorkBot keeps each customer's data apart, and it runs on the embedded database in tests.
  • Popping a queue reads the old head through the update's FROM clause. Only the thread's lock holder pops, so two readers never race for one entry.
  • The SDK also has a GitHub adapter, and GitHub issues are WorkBot's tickets (Day 27). With a personal token, the bot and the person commenting are the same account, so the adapter ignores the comment as its own. A GitHub App gives the bot an identity of its own.

Channel replies and hand-offs

Every channel now reaches the same turn, but a reply that reads well on the web can break on SMS. Some conversations also need a person, on whatever channel they started.

Study first

  • Each channel constrains the reply. SMS takes plain text, WhatsApp takes bold, and Slack takes threads and cards. Tell the model, and enforce it on what comes back. Twilio's page on SMS length explains segments, 160 characters in the basic alphabet but 70 once a message holds an emoji.
  • A human handoff escalates to a person on any channel with the transcript, so nobody asks the customer to start again.
  • Know who is writing. A stranger texting the business number is a customer, while a signed-out visitor on the web might be staff.

Build

  1. Add a line per channel that tells the model how to write. Write render, which strips markdown for SMS, converts Markdown bold to WhatsApp's bold, and holds each channel's length limit.
  2. Write hand_off in src/lib/handoff.ts, a tool offered on every channel. It files a request for the team that looks after customers, with the reason, the conversation and where to reply.
  3. On SMS and WhatsApp, tell the model that an unlinked sender is a customer. The model then answers from the customer-facing documents, does not send the customer to an internal team, and hands off when the customer needs a person.

Done when

  • A customer who asks for a person on WhatsApp gets a request filed with the transcript, and the next turn knows it was filed.
A hand-off
A WhatsApp customer's damaged delivery is handed to support. The request says where to reply, gives the reason, and holds the conversation from its start on the web.
Technical notes
  • Give the customer note to SMS and WhatsApp only. In the reference run it first went to every signed-out caller, the web included, and the chat eval's smoke set fell from 0.94 to 0.80. The chat eval is Day 19's scored test of the assistant's answers. Its smoke set is the small subset from Day 25 that runs on every pull request.
  • That set's cases expect the web assistant to name the team or the approver, because the set was written for employees who had not signed in. Who the signed-out web visitor is remains a product decision, and the eval holds it until someone makes it.
  • Keep a channel turn's tool calls and results, as the web does. In the reference run, a channel turn kept only its reply's text, so on the next message the model saw "I've filed request ca66be2f…" with no tool call behind it. It said it had not really filed anything, and filed again. Add a test that checks the next turn sees the tool calls.
  • Accept a hand-off's reason at any length. In the reference run the first hand-off failed, because the model's reason was longer than the 100 characters the tool allowed. Clip the title, and keep the reason whole in the body.

Run it and plan going live

With every piece built, run one conversation across two channels on your machine. Then write down what going live needs, because some of it needs accounts that you may not have.

Study first

  • iMessage has no public API for business. Apple Messages for Business runs through Apple's approved messaging providers, with webhooks in and an API out. Apple's guide to Messages for Business shows the path from customer to provider to business. SMS reaches nearly every phone and is the fallback, with sender rules that vary by country and number type. In the US, a business texting from an ordinary 10-digit number must first register its brand and campaign, while toll-free numbers and short codes follow other rules.

Build

  1. Set APP_URL, and a TWILIO_AUTH_TOKEN of your own to sign the test messages with. Point the Inngest dev server at the app.
  2. On the chat page, ask about a delivery and take a code. Then post messages to /api/webhooks/twilio, signed the way Twilio signs them, in this order: link <code>, a follow-up, and a damaged-goods complaint. Every model turn is real, but nothing proves delivery to or from Twilio.
  3. Open the chat page again, and then /admin/channels. The chat page shows the WhatsApp turns, and the Channels page shows who has linked, each message sent, and why it did not leave the machine.
  4. In docs/channels.md, list what going live needs. SMS needs a Twilio account and numbers, and the webhook URL set in Twilio's console. WhatsApp also needs a sender approved by Meta, a template for outside the window, and opt-in collected before WorkBot writes first. Slack needs a Slack app installed with the manifest.
  5. Write down what is not built. In the reference run, that is iMessage, quiet hours, typing indicators and attachments. It also lacks an alert when a hand-off arrives, which waits in the team's requests, and no person can reply from WorkBot to the channel a hand-off came from.

Done when

  • The transcript reads as one conversation across two channels, with each tool call and hand-off in place.
  • Someone with the accounts can turn each channel on from the environment alone, and knows which claims stay unproven until then.
The web after WhatsApp
The web shows the same conversation, with the WhatsApp turns labeled where they came from.
The conversation
One conversation runs from a web question to WhatsApp, with the documents read, two hand-offs and their replies.
The Channels page
In the reference run, the Channels page shows which channels are on, and the linked sender with WhatsApp's window open. Each reply is marked failed, because no Twilio account is configured.
Technical notes
  • Restart the dev server after a check. Next.js 16 points next-env.d.ts at the dev server's types, and next typegen or a build points it at their own. In the reference run, that change sent a running dev server into a reload loop that froze the browser.

Resources