# How we built an omnichannel agent | Enrich Labs

> What we learned bringing Helena to web chat, email, Slack, SMS and iMessage, and MCP hosts: the UX each surface demanded and the plumbing it took.

_Source: https://www.enrichlabs.ai/engineering/omnichannel-agent_

---

## The premise: one brain is not enough

Helena, our AI marketing agent, started life as a web chat. Over the past year we brought her to email, Slack, SMS and iMessage, and MCP hosts such as Claude and ChatGPT. Those channels now carry about half of her conversation traffic, and nearly half of new signups start inside Claude or ChatGPT rather than on our website.

Each time we added a surface we expected a thin adapter: take the message, run the same agent, reformat the answer. That never held. The reasoning, the tools, and the business context are shared. Everything around them changed: how long an answer should be, how a user interrupts, what they can see, and how we know which conversation they are continuing.

This post walks through each surface in turn: what the experience needed, what we built, and why.

The shared system stops at the conversation boundary. What counts as a conversation, an interruption, a delivery, and a finished answer is decided per channel.

**Our operating model:** share the agent’s brain, then give every surface its own conversation contract.

Channel 1

## Web chat: a live view on a job

Web chat is where Helena started, and the main thing we learned there is that the chat window is a view on a job, not the job itself. Helena’s tasks often run for many minutes. People start one, then close the laptop, switch tabs, or walk out of Wi-Fi range. If the work stopped every time the connection did, long tasks would rarely finish.

So a dropped connection never stops the work. On the server, each turn has two separate stop signals. One fires when the browser connection closes, and all it does is stop sending updates to a stream nobody is reading. The other actually cancels the model, and it fires when the user presses Stop. Closing a tab is ambiguous. Pressing Stop is not.

Left: Stop and steering travel from the tab that owns the stream, through Redis, to the server running the turn, which picks steering messages up between steps. Right: a tab without the stream never rejoins it. It polls for what the turn has saved.

Stop is the blunt way to interrupt. Steering is the gentle one. Users often think of a correction while Helena is mid-task: “actually, use last month’s numbers”. Without steering, the choices are to wait for the turn to finish or to press Stop and start over. With it, a message sent during a turn is delivered into the running turn. The agent loop checks for these messages between steps, after the current tool call finishes and before the next model call, so Helena picks up the correction without throwing away the work she has already done.

Because the work does not depend on the window, several chats can run at once. People start a long task in one conversation and open another while they wait. Each conversation the user has visited keeps its own live stream, so switching between chats does not interrupt the work or mix one chat’s updates into another. A small indicator shows which other chats are still working, and puts a chat that is waiting for the user’s approval first.

Coming back after closing the tab is simple, because the turn has been saving its progress to the database all along. The page reads what is there. If the turn is still running, it shows that Helena is working and checks back every few seconds until she is done. It never tries to rejoin the old stream.

Channel 2

## Email: a finished artifact on an unreliable thread

Email is the one channel where Helena has to work out which conversation a message belongs to, and it turns out email threading rests on very fragile mechanisms. Three headers (Message-ID, In-Reply-To, and References) are supposed to say which thread a message is part of. In practice every party along the way rewrites them. SendGrid returns a bare id when we send. The recipient’s mail client quotes that id back with a domain attached. Gmail will not thread two messages at all unless their subjects also match.

We need threading to work, because the thread is how Helena knows which conversation a reply continues. If the lookup misses, the reply opens a blank conversation and Helena has no idea what the user is answering. So we stopped relying on the headers alone. We keep our own ledger of every message id we have sent or received, and match an incoming reply against several normalized forms of its id. Every email Helena sends is recorded there, including automated ones like weekly reports, since those are exactly the emails people reply to.

How a reply finds its session. The bare id from the send API and the fully qualified id the client quotes back are the same message; the ledger matches on normalized variants, and the matched thread decides the brand.

People read email differently from chat. A chat reply can be short and conversational, because the next message is seconds away. An email has to stand on its own: one composed answer the reader can act on without a follow-up.

So over email Helena drops her chat habits. No emoji, no narrating what she is doing (“Perfect, now I have the data”), and no sign-off, because the signature is added separately. Anything interactive has to become something a mail client will actually show. Gmail strips scripts, so a chart that is interactive in the web app is rendered to a static image, and a download card becomes a plain link.

Channel 3

## Slack: one headline, one thread

A Slack channel is shared space. Helena’s scheduled reports are long, often several screens, and posting one in full pushes everyone else’s conversation out of view.

So a report arrives as two things. The channel gets a short headline: a few lines on what happened and whether anyone needs to act, written by a small, fast model from the finished report. The full report sits in the thread under it, for whoever wants the detail. Follow-up questions happen in that same thread, so the channel stays readable and the discussion stays attached to the report it is about.

The root message is the durable headline; the thread holds the full report and the follow-up conversation.

In a channel, Helena also has to work out when she is being spoken to. A mention or a DM is an explicit invitation. A message in a thread she is already part of is less clear: it might be for her, or it might be two teammates talking to each other. Those go to a small classifier first, which decides whether she should answer. And if she is already working when a follow-up arrives, the new message is steered into the run in progress, the same way as on the web, rather than starting a second one.

Helena is not the only agent in the room, either. Our specialist agents each install as their own Slack app, and a team can have several in one channel. The rule that keeps this sane is that only humans trigger agents. Agents can read each other’s messages in a thread and build on them, but a message written by a bot never starts a run, so two agents cannot @mention each other into a loop. When a person tags two agents in one message, both answer.

Channel 4

## SMS and iMessage: the answer is a sequence

Texting is the most personal channel Helena is on, and the one where she can most easily feel like a bot. In a text thread people expect the rhythm of a person: a quick acknowledgement, a few short messages, a pause while something gets looked up. One long reply that lands two minutes later breaks that.

So liveliness is the main design goal. As soon as a text arrives, Helena marks it read and shows the typing indicator, before any work starts, so the user can see she is on it. iMessage drops the indicator after a few seconds, so we refresh it before every message. Otherwise a 40-second lookup looks like a dead thread.

The replies themselves go out through a tool. Instead of composing one final answer, Helena calls a send-message tool whenever she has something to say, as many times as she likes within a turn. “Checking Klaviyo now” goes out while Klaviyo is being checked, and the numbers follow when they are ready. On iMessage she can also react to a message when a reaction says enough. Because the messages have already been sent, the usual closing summary is dropped; it would only repeat them.

The same result shaped three ways. Email is one long document, Slack a headline with a thread, text a handful of bubbles with a reaction. The shape is a product decision, not a formatting pass after generation.

A text thread has no “new chat” button, so we had to decide what a conversation is. The session is a rolling 24-hour window scoped to the brand, which means “do those” still makes sense the next morning. Only a message from the user extends it. Helena’s own daily alert does not, or a thread opened in March would still be accumulating context in September.

Onboarding starts with the user, not with Helena. The connect button opens the phone’s Messages app with “Hi Helena” already typed, and the user sends it. A text the user sent is stronger consent than a ticked checkbox, it proves the number is theirs, and Helena’s reply lands in a thread they started instead of arriving from an unknown number like spam. Nothing proactive is switched on until that first text arrives.

Opting out gets the same priority. On our main texting provider, STOP is handled before anything else in the pipeline, ahead of the conversation lock and the model, so an unsubscribe is never delayed because Helena is busy with a long task.

Channel 5

## MCP: one protocol does not mean one experience

Over MCP, Helena appears inside someone else’s product, Claude or ChatGPT, and we are trying two modes of integration right now. In the first, Helena is an agent. The host hands her the whole request, and she runs it with her own model, memory, skills, and tools. In the second, Helena is a set of tools. The host’s model does the planning and calls specific tools of ours, such as scheduling a post or reading ad results, shipped as one connector per channel.

Agent mode brings everything Helena knows about the brand, but the user waits on a second agent. Tools mode is quick and easy for a host to review, but the host’s model is now the one doing the marketing. We do not have a winner yet.

The two ways we connect Helena to a host today. In the first, the host hands the whole request to Helena. In the second, the host’s own model does the planning and calls Helena’s tools one at a time.

The two hosts also show results very differently. Claude renders Markdown, so an image goes back as an image link in the text, plus a copy only the model can see, so Claude can look at what it is showing. ChatGPT lets a connector ship its own UI, called an App: an HTML page we serve through the connector, which ChatGPT renders in a frame inside the conversation. We use it for a progress card that turns into the finished image or video, and for Helena’s workspace, with the calendar, performance charts, scheduled tasks, and files.

* * *

The same result, as the user sees it in each host

Helena returns

In Claude

In ChatGPT

An image

Shown inline in Claude’s reply

Shown in Helena’s App

A video or audio clip

A link to open

Plays in the App

A chart

A static picture of the chart

A chart drawn by the App from the numbers

A file

A download link

Listed in the App’s Files view

A long-running job

A series of status checks in the chat

One progress card that becomes the result

* * *

Two things about building an App were not obvious. It has no login of its own, so it asks ChatGPT to call our tools over the connection the user already authorized. And it looks but does not act: its buttons send a message into the conversation, so the work, and any approval, still happens in the chat.

Approval depends on the mode. In tools mode the host’s model calls the publishing tool, so the host asks the user in its own UI. In agent mode Helena decides to publish in the middle of a turn, and the host cannot show her approval card. So her first attempt is rejected back to the host with instructions to show the draft and ask, and the approval only counts from the user’s next message.

## The shared brain needs five channel contracts

We still believe in one agent across channels. Sharing tools, business context, and core reasoning keeps five systems from drifting apart. But every surface needed its own answers to the same questions.

* * *

What changes from channel to channel

Channel

A good answer is

The user changes course by

The hard part underneath

Web chat

A live view of a running job

Steering mid-task, or pressing Stop

Keeping the work alive when the tab is gone

Email

One composed message

Replying

Matching a reply to its thread

Slack

A headline, with the detail in a thread

Replying in the thread

Knowing when Helena is being spoken to

Text

A few short messages, sent as she works

Sending another text

Consent, and deciding what a conversation is

MCP

Whatever the host can show

Telling Claude or ChatGPT

Two hosts that show the same result differently

* * *

That was our biggest lesson from taking Helena omnichannel: **we could reuse the brain, but every surface taught it a new way to be present.**

_We’re [Enrich Labs](https://enrichlabs.ai). Helena is our AI marketing agent. If building AI agents that work in real-world environments is the kind of problem that excites you, we’d love to talk._

## References

-   TanStack, [Resumable streams](https://tanstack.com/ai/latest/docs/resumable-streams/overview), and Vercel, [Chatbot resume streams](https://ai-sdk.dev/v7/docs/ai-sdk-ui/chatbot-resume-streams)
-   OpenAI, [15 lessons learned building ChatGPT Apps](https://developers.openai.com/blog/15-lessons-building-chatgpt-apps)
-   Slack, [Events API: acknowledgement and retries](https://docs.slack.dev/apis/events-api/)
-   Google, [Gmail threading requirements](https://developers.google.com/workspace/gmail/api/guides/threads)
-   Model Context Protocol, [Tools specification](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) and [the 2026-07-28 release notes](https://blog.modelcontextprotocol.io/posts/2026-07-28/)
