Building a Multi-Channel AI Agent with Twilio Conversations and eve

October 08, 2026
Written by
Reviewed by
Paul Kamp
Twilion
Anni Chen
Twilion

Building a Multi-Channel AI Agent Twilio with Conversations and eve

Building a Multi-Channel AI Agent with Twilio Conversations and eve

Getting an AI agent to answer a customer's SMS is easy. Getting it to remember that customer next week when they message you on a different channel? That’s where most agent projects give up and start writing their own memory layer.

Twilio helps remove that step with one of our tools that we launched earlier this year: Twilio Conversations. Pair it with Vercel's eve framework, and together, they draw a clean line between the two types of memory an agent needs:

  1. Vercel’s eve: holds the short-term memory the running transcript of the current conversation
  2. Twilio Conversations: hold the long-term memory knowledge of the current person (customer profiles and observations extracted automatically)

With these two tools, you can spend your time on the agent's behavior rather than the plumbing. Now you can plug in the LLM that you want.

In this post, you'll build an agent that unifies SMS and WhatsApp into one thread, remembers what each customer told you last month, and lets you swap the model in just one line.

Prefer to skim the code first? The full sample project is on GitHub at IObert/twilio-conversations-eve-agent. Clone it, install the dependencies, drop your credentials and service ids into .env, and the rest of this post is a guided tour of what's already there.

What you'll build

Picture this: a customer texts your support line asking about the return policy on the blue running shoes they ordered. Ten days later they message you on WhatsApp, follow up on that same order, and ask a new question.

The interesting facts about that customer are things like their name, the products they bought, that they prefer WhatsApp for anything urgent, and the last few questions they asked. If you persist only those facts and inject the relevant ones selectively, prompts stay small, debuggable, and stable across sessions.

This is the separation of concerns you'll build below. eve keeps a compact per-conversation transcript for the current thread. Twilio's Memory Store keeps the extracted facts across every past thread. A dynamic instruction merges these two types of information pieces at the start of each session.

An architecture diagram reading left to right. On the left, one customer with a single phone number sends over two channels, SMS and WhatsApp. Both feed into a red-bordered "Twilio Conversations" panel holding three pieces: Orchestrator (one profile per person, one conversation across every channel), Memory Store (who the customer is and what they've said), and Intelligence (extracts the facts when a conversation closes, writing them back to the Memory Store). The panel is labeled "long-term memory". Two arrows cross to a blue-bordered "eve" panel: a red `COMMUNICATION_CREATED` webhook and a dashed blue arrow carrying the customer profile. In eve, `channels/twilio-orchestrator.ts` is highlighted as the custom channel you write; everything below it is marked "baked in" — `instructions.md` for the system prompt, `agent.ts` for any AI SDK model, `tools/` and `skills/` for what the agent can do, and the session transcript as short-term memory. A blue path runs back to the customer: the reply goes out on whichever channel they just used.
An architecture diagram reading left to right. On the left, one customer with a single phone number sends over two channels, SMS and WhatsApp. Both feed into a red-bordered "Twilio Conversations" panel holding three pieces: Orchestrator (one profile per person, one conversation across every channel), Memory Store (who the customer is and what they've said), and Intelligence (extracts the facts when a conversation closes, writing them back to the Memory Store). The panel is labeled "long-term memory". Two arrows cross to a blue-bordered "eve" panel: a red `COMMUNICATION_CREATED` webhook and a dashed blue arrow carrying the customer profile. In eve, `channels/twilio-orchestrator.ts` is highlighted as the custom channel you write; everything below it is marked "baked in" — `instructions.md` for the system prompt, `agent.ts` for any AI SDK model, `tools/` and `skills/` for what the agent can do, and the session transcript as short-term memory. A blue path runs back to the customer: the reply goes out on whichever channel they just used.

Figure 1: Architecture diagram showing how Twilio Conversations and Vercel’s eve integrate with each other

Meet the stack

eve is Vercel's file-system-first framework for durable AI agents. You author files, and eve compiles them into a runtime that manages sessions, tool calls, streaming, and durable state across crashes and redeploys. The project is also open source on GitHub.

Twilio Conversations is our suite of AI-native products that go beyond the traditional scope of our communications APIs. Underneath it, there are three pieces:

  • Conversation Orchestrator:a durable, cross-channel conversation resource that stitches SMS, WhatsApp, RCS, chat, and voice into one thread per customer. As the developer, you decide how traffic on each channel should be treated and whether to connect it to the two other Conversations sub-products below.
  • Memory Store: a persistent, per-customer profile database with traits (such as name, phone, preferences) and observations (such as facts extracted from past conversations).
  • Conversation Intelligence: runs language operators for summaries, sentiment, and observation extraction against conversations, either in real time or at the end of a conversation.

The mental model to hold onto: Orchestrator handles the "who is this and where do I reply?" question. Memory Store handles the "what do I already know about them?" question, and Intelligence writes new facts to Memory Store.

Prerequisites

You'll need a handful of things before you start:

  • Node.js 24 or newer
  • A Twilio account with an Account SID and Auth Token (find both in the Twilio Console)
  • At least one SMS-capable Twilio phone number
  • A WhatsApp sender attached to a phone number. It can be the same number you use for SMS, or a second dedicated number. Both work with this setup.
  • An OpenAI API key, or your provider of choice ( any AI SDK provider works)
  • A shell for the provisioning calls. macOS and Linux ship with curl. On Windows, to run the curl versions verbatim, use WSL or Git Bash.
  • ngrok or a similar tool for local webhook tunneling. ngrok is a tool that gives you a public HTTPS URL that forwards to a port on your laptop, so Twilio can reach your webhook while you develop.

Bootstrap the project

Let’s initialize a new project, and get all the packages we need. In your terminal, use the following command:

npx eve@latest init elephant-agent
cd elephant-agent
pnpm add @ai-sdk/openai twilio

You might be asked to “install the following packages” and a reference to eve’s latest version. It’s okay to hit “y” here to install eve.

The twilio npm package is what you'll use later to verify Orchestrator webhook signatures. @ai-sdk/openai is the AI SDK provider you'll wire the model through.

eve init generates the project scaffold with everything a hello-world agent needs:

elephant-agent/
├── agent/
│   ├── agent.ts          # model + runtime config
│   ├── channels/
│   │   └── eve.ts        # local CLI channel for pnpm dev
│   └── instructions.md   # system prompt
├── package.json
└── tsconfig.json

For this tutorial, the default instructions.md is fine.

You can grow the instructions with additional slots as the agent needs them:

The wizard already created the agent/channels/eve.ts, which exposes a local CLI channel: run pnpm dev and you get an interactive terminal to chat with the agent without touching any external service. Your Twilio channel will live in this same folder alongside it.

Configure the model

Now let’s configure our agent/agent.ts file. Open the file in your editor of choice, and you’ll see the file contains:

import { openai } from "@ai-sdk/openai";
import { defineAgent } from "eve";
export default defineAgent({
  model: openai("gpt-5.6-luna"),
  modelContextWindowTokens: 400_000,
});

To use a different provider, install its AI SDK package and swap the model where it’s referenced in the file.

Note for Zero Data Retention orgs. If your OpenAI organization has Zero Data Retention on, the default Responses API call fails with "Items are not persisted for Zero Data Retention organizations." Add modelOptions: { providerOptions: { openai: { store: false } } } to the config and it works.

Set up environment variables

Create a .env file at the project root. The comments below tell you where each value comes from, so you can fill in the top block before moving onto the next step

# .env
# ── Fill in now ─────────────────────────────────────────────
# Twilio credentials. Both live on the Twilio Console dashboard:
# https://console.twilio.com (Account SID at the top, Auth Token below).
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Model provider. Create a key at https://platform.openai.com/api-keys
OPENAI_API_KEY=sk-...
# Twilio senders
TWILIO_SMS_NUMBER=+15551234567
TWILIO_WHATSAPP_NUMBER=whatsapp:+15551234567
# ── Fill in after starting ngrok ─────────────
PUBLIC_BASE_URL=
# ── Fill in after Twilio provisioning (a few sections down) ─
MEMORY_STORE_ID=
ORCHESTRATOR_CONFIG_ID=

Leave the last two empty for now, as you'll fill them in a few steps below.

Start ngrok before provisioning

You need a public HTTPS URL before you create the Orchestrator Configuration, because that curl call registers the webhook URL Twilio will call. If you are using a free ngrok account, it will get a fresh random hostname on every start. Start the tunnel now and paste the URL into .env as PUBLIC_BASE_URL:

ngrok http 2000

Copy the https://…ngrok… hostname from the ngrok output, set it in .env as PUBLIC_BASE_URL.

Once the file is saved on disk, load every variable into your current shell so that curl and pnpm dev see the same values:

set -a && source .env && set +a

Provision the Twilio side

Memory Store and Orchestrator Configuration are a one-time setup. Both are REST resources.

Create the Memory Store

Now let’s create the Memory Store. In the terminal, use the following commands:

curl -s -X POST "https://memory.twilio.com/v1/ControlPlane/Stores" \
  -u "$TWILIO_AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"displayName":"elephant-agent-store"}'

The response is a 202 with a statusUrl that looks like https://memory.twilio.com/v1/ControlPlane/Operations/mem_configop_XXXXXXXX. That's Twilio's way of saying "I've accepted the job, check this URL when you're ready." Confirm with a plain “GET” against the statusUrl from your own response (substitute your op_... id for the placeholder below):

curl   -u "$TWILIO_AUTH_TOKEN" \
  "https://memory.twilio.com/v1/ControlPlane/Operations/op_XXXXXXXX"

Look for "status": "COMPLETED" and the mem_store_... id in the response body. Copy the id into .env as MEMORY_STORE_ID, then reload the shell:

set -a && source .env && set +a

Create the Orchestrator Configuration with GROUP_BY_PROFILE

Now all the values that your requests need are now in your environment. Use a heredoc so Bash substitutes the variables inline. 

curl -s -X POST "https://conversations.twilio.com/v2/ControlPlane/Configurations" \
  -u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @- <<EOF
{
  "displayName": "elephant-agent-config",
  "description": "Cross-channel Conversation Orchestrator for the elephant-agent memory demo. Groups SMS and WhatsApp traffic by customer profile and hands turns to the eve runtime.",
  "conversationGroupingType": "GROUP_BY_PROFILE",
  "memoryStoreId": "$MEMORY_STORE_ID",
  "memoryExtractionEnabled": true,
  "channelSettings": {
    "SMS": {
      "captureRules": [
        {"from": "*", "to": "$TWILIO_SMS_NUMBER", "metadata": {}},
        {"from": "$TWILIO_SMS_NUMBER", "to": "*", "metadata": {}}
      ]
    },
    "WHATSAPP": {
      "captureRules": [
        {"from": "*", "to": "$TWILIO_WHATSAPP_NUMBER", "metadata": {}},
        {"from": "$TWILIO_WHATSAPP_NUMBER", "to": "*", "metadata": {}}
      ]
    }
  },
  "statusCallbacks": [
    {"method": "POST", "url": "$PUBLIC_BASE_URL/eve/v1/twilio-orchestrator/webhook"}
  ]
}
EOF

Two settings are worth calling out:

  • conversationGroupingType: GROUP_BY_PROFILE unifies the same customer across channels into one conversation, keyed on the Memory Store profile rather than on the address the customer uses. The default, GROUP_BY_PARTICIPANT_ADDRESSES_AND_CHANNEL_TYPE, separates SMS and WhatsApp into separate conversations. GROUP_BY_PARTICIPANT_ADDRESSES groups channels together, but only when the customer uses the same address on each. This means that if a customer sends an SMS from one number, but a WhatsApp message from another number, then those channels won’t be grouped. GROUP_BY_PROFILE is what Twilio recommends for production.
  • memoryExtractionEnabled: true turns on the intelligence pipeline that writes observations back to Memory Store when a conversation closes.

Note: don't configure a webhook on the phone number or on the WhatsApp sender. The captureRules above are what pull traffic in, and the single statusCallbacks URL is where Orchestrator delivers it.

Here's what GROUP_BY_PROFILE buys you, once everything below is wired up:

Two dark-mode phone screenshots side by side. The left is an SMS thread where the customer mentions blue running shoes and asks about the return policy. The right is a WhatsApp thread ten days later where the customer writes only "Quick follow up on that order: Can I still return them?" and the agent replies with the return window for that shoe order, without the customer having repeated any details.
Two dark-mode phone screenshots side by side. The left is an SMS thread where the customer mentions blue running shoes and asks about the return policy. The right is a WhatsApp thread ten days later where the customer writes only "Quick follow up on that order: Can I still return them?" and the agent replies with the return window for that shoe order, without the customer having repeated any details.

Figure 2: One continuous conversation that started on the left via SMS and continues on the right via WhatsApp

Notice that the WhatsApp message didn’t contain information about the product, the order, or any reminder of the earlier conversation. That context came from the Memory Store profile that Conversation Orchestrator grouped both channels into.

This call also returns a 202 with a statusUrl. You can confirm the call in the same way, substituting your own op_... id:

curl -u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN" \
  "https://conversations.twilio.com/v2/ControlPlane/Operations/op_XXXXXXXX"

Once the response shows "status": "COMPLETED", copy the conv_configuration_... id into .env as ORCHESTRATOR_CONFIG_ID.

First attempt: eve's built-in Twilio adapter

Before you write anything custom,try eve’s built-in Twilio adapter:

// agent/channels/twilio.ts
import { twilioChannel } from "eve/channels/twilio";
export default twilioChannel({
  allowFrom: "*",
  messaging: { from: "*" },
  async onText(ctx, message) {
    await ctx.twilio.sendMessage(`Copy: ${message.body}`);
    return null;
  },
});

Point your Twilio phone number's inbound messaging webhook at $PUBLIC_BASE_URL/eve/v1/twilio/messages, run pnpm dev, and text your number. Awesome, you built a working SMS agent with just a few lines of code!

A dark-mode SMS thread titled "Twilio Conversations". A green outgoing bubble reads "Hi 👋 This is a test"; the gray reply below it reads "Copy: Hi 👋 This is a test".
A dark-mode SMS thread titled "Twilio Conversations". A green outgoing bubble reads "Hi 👋 This is a test"; the gray reply below it reads "Copy: Hi 👋 This is a test".

Figure 3: A green outgoing bubble reads "Hi 👋 This is a test"; the gray reply below it echos the message

If you created this file, delete it before moving to the next steps. Clear the inbound webhook you just set on the phone number, and delete the agent/channels/twilio.ts file. The Orchestrator setup ingests traffic through capture rules, and if the per-number webhook is still pointing at the built-in adapter, both paths fire on every inbound message and your customer gets two replies.

Eve’s Twilio adapter is a well-organized cluster of eight TypeScript modules under packages/eve/src/public/channels/twilio that handle:

  • Webhook signature verification against Twilio's HMAC scheme
  • Parsing the form-encoded body (SMS, MMS metadata, voice transcription callbacks)
  • Building continuation tokens so a caller keeps the same session between messages
  • A sendMessage helper that resolves the reply's from and to from the inbound
  • Voice support via <Gather> and status callbacks
  • Default handlers for message.completed and turn.failed so you get a working reply out of the box

For a proof-of-concept, this is excellent. But you might run into issues if you want to advance your use-case: This adapter neither supports media, nor provides a useful voice integration, and all sessions are tied to the sender address, which means the SMS and WhatsApp channel are distinct. You essentially lose everything Twilio Conversations gives you. So to avoid that, let’s write your own adapter that allows agents to remember what the customer told you during the previous conversation.

Building an Orchestrator-aware channel

The Twilio Messaging API webhook fires per incoming message and you can infer the details from its own form-encoded payload. Conversation Orchestrator sits one layer up, where you subscribe to events on the Orchestrator Configuration itself, and every message (inbound or outbound) and additional event types arrives at a single JSON webhook keyed by conversationId.

That's a different webhook shape than the one eve's built-in adapter handles. So let’s write an adapter to handle the JSON payload.

The imports and setup

Create agent/channels/twilio-orchestrator.ts with these sections:

import { defineChannel, POST } from "eve/channels";
import { resolveTwilioAuthToken, sendTwilioMessage, type TwilioChannelCredentials } from "eve/channels/twilio";
import twilio from "twilio";
import { linkCrossChannelIdentity } from "../lib/memory";
const publicOrigin = process.env.PUBLIC_BASE_URL!;
// Left empty on purpose: eve's Twilio helpers fall back to TWILIO_ACCOUNT_SID
// and TWILIO_AUTH_TOKEN from the environment. Fill this in only if you need to
// override them per-channel (a subaccount, or an API key pair).
const credentials: TwilioChannelCredentials = {};
// Passive capture rules also fire on our own outbound, so filter echoes.
// Your senders are known at startup, so this set is static. Both the bare and
// the whatsapp:-prefixed form of each, since the webhook may report either.
const AGENT_ADDRESSES = new Set<string>(
  [process.env.TWILIO_SMS_NUMBER!, process.env.TWILIO_WHATSAPP_NUMBER!].flatMap((a) => {
    const bare = a.startsWith("whatsapp:") ? a.slice("whatsapp:".length) : a;
    return [bare, `whatsapp:${bare}`];
  }),
);
// Shape of the Orchestrator webhook body we actually read. Extend as
// you handle more event types.
type WebhookEvent = {
  eventType: string;
  data?: {
    conversationId?: string;
    author?: { address?: string; channel?: string; participantId?: string };
    content?: { type: "TEXT" | "TRANSCRIPTION"; text?: string };
    recipients?: Array<{ address?: string; channel?: string }>;
    // PARTICIPANT_ADDED shape
    type?: string;
    profileId?: string;
    addresses?: Array<{ channel?: string; address?: string }>;
  };
};
// The routing info we stash on auth.attributes so it refreshes on every send.
type RouteAttrs = {
  channel?: string;
  customerAddress?: string;
  agentAddress?: string;
};
// Twilio expects the "whatsapp:" prefix on outbound WhatsApp addresses.
function withChannel(channel: string | undefined, address: string): string {
  if (channel === "WHATSAPP" && !address.startsWith("whatsapp:")) return `whatsapp:${address}`;
  return address;
}
// ...continues below

In the above code, these are the main components::

  • AGENT_ADDRESSES is the echo filter, built once at startup (more on this below).
  • ! on process.env.PUBLIC_BASE_URL is a promise to TypeScript that the variable is set. If you forget to fill it in, signature verification compares against undefined/eve/...In production, replace the ! with a real startup check that throws.
  • The two types and the withChannel helper are declared here so the rest of the file can reference them freely. Read on further to see where each component is used.

Verifying the webhook

The default verifyTwilioRequest in eve/channels/twilio implements Twilio's HMAC-over-form-params scheme. This is the scheme that every classic Twilio Messaging or Voice webhook uses. However, Orchestrator webhooks are different.

For Conversation Orchestrator, the body is JSON, not form-encoded. Twilio appends a bodySHA256 query parameter and signs the URL against the raw body bytes. Thus, you need to open the channel definition and its first route::

// ...continued from above
function toPublicUrl(request: Request): string {
  const url = new URL(request.url);
  return `{url.pathname}${url.search}`;
}
export default defineChannel({
  routes: [
    POST("/eve/v1/twilio-orchestrator/webhook", async (request, { from, waitUntil }) => {
      const rawBody = await request.text();
      const signature = request.headers.get("x-twilio-signature") ?? "";
      const authToken = await resolveTwilioAuthToken(credentials.authToken);
      if (!twilio.validateRequestWithBody(authToken, signature, toPublicUrl(request), rawBody)) {
        return new Response("unauthorized", { status: 401 });
      }
      // ...continues below

The twilio Node SDK's validateRequestWithBody reads the raw body once, verifies it, then parses it. toPublicUrl rebuilds the URL Twilio actually called (using PUBLIC_BASE_URL).

Parsing and filtering echoes

Orchestrator sends many event types (CONVERSATION_CREATED, PARTICIPANT_ADDED, COMMUNICATION_CREATED, etc.).

The two we care about most here are:

  • PARTICIPANT_ADDED: fires when a customer joins a Conversation, and it's the hook that’s used to link their identities across channels (more on that below).
  • COMMUNICATION_CREATED: carries the message body.

Capture rules are bidirectional. When your agent sends a reply, Orchestrator captures that message too and delivers a fresh COMMUNICATION_CREATED event to your webhook.

Without a filter, the agent replies to its own reply, forever. (Ask me how I know!)

That's what AGENT_ADDRESSES is for – it's a static set built at startup: the only addresses you ever send from are the two senders already in .env.

TWILIO_WHATSAPP_NUMBER carries the whatsapp: prefix because outbound sends need it, but the webhook doesn't consistently report the author in that same form. Normalizing to the bare number and storing both spellings means the filter matches either way.

// ...continued from above
      const ok = new Response("ok", { status: 200 });
      const event = JSON.parse(rawBody) as WebhookEvent;
      // Under GROUP_BY_PROFILE, hydrate the sibling identifier on the CUSTOMER's
      // profile so the next inbound on the other channel joins the same conversation.
      if (event.eventType === "PARTICIPANT_ADDED") {
        const d = event.data;
        const addr = d?.addresses?.[0];
        if (d?.type === "CUSTOMER" && d.profileId && addr?.address) {
          waitUntil(linkCrossChannelIdentity(d.profileId, addr.address, addr.channel));
        }
        return ok;
      }
      if (event.eventType !== "COMMUNICATION_CREATED") return ok;
      const { conversationId, author, content, recipients } = event.data ?? {};
      const customerAddress = author?.address;
      // Voice arrives as TRANSCRIPTION; skip until voice replies are wired up.
      const text = content?.type === "TEXT" ? content.text : undefined;
      if (!conversationId || !customerAddress || !text) return ok;
      if (AGENT_ADDRESSES.has(customerAddress)) return ok;
      // ...continues below

Twilio Memory resolves customers by identity trait: SMS, Voice, RCS, and MMS lookup under phone, and WhatsApp under whatsapp.

The same person on both channels would create two profiles (and land in two separate conversations!) unless you hydrate the sibling identifier at the exact moment the first profile is created. That's what linkCrossChannelIdentity does.

Dispatching the turn

Use auth.attributes, not state, for the reply channel eve's state is sticky. It's set once when the session is created and reused unchanged on every follow-up turn. auth.attributes refresh on every send, so if you put the channel and addresses there, eve answers on whichever wire the customer just used.

Applied to your dispatch:

// ...continued from above
      const recipient = recipients?.[0];
      const channel = author?.channel ?? recipient?.channel;
      const agentAddress = recipient?.address;
      waitUntil(
        from(conversationId).send(text, {
          auth: {
            attributes: {
              customerAddress,
              ...(channel ? { channel } : {}),
              ...(agentAddress ? { agentAddress } : {}),
            },
            authenticator: "twilio-orchestrator-webhook",
            issuer: "twilio",
            principalId: `twilio-orchestrator:${author?.participantId ?? customerAddress}`,
            principalType: "user",
          },
        }),
      );
      return ok;
    }),
  ],
  // ...continues below

Notice: there is no state in the send options. Everything the reply needs is on auth.attributes, which refresh on every turn.

participantId comes from Twilio Conversations: it's Orchestrator's id for one participant within one Conversation. principalId comes from eve, an app-level actor tag eve keeps on the session so your code can tell callers apart.

participantId is scoped to the conversation in Twilio Memory, which is the scope eve's session has. Long-term identity is the Memory Store's job, and the next section hands that off properly.

Reading auth.attributes on the reply side

The context function of defineChannel is called every time an event handler needs to talk to the channel. That's where you read the fresh routing info:

// ...continued from above
  context(_state, session) {
    const { channel, customerAddress, agentAddress } =
      (session.auth.current?.attributes ?? {}) as RouteAttrs;
    return {
      async sendMessage(body: string) {
        if (!customerAddress || !agentAddress) return null;
        return sendTwilioMessage({
          credentials,
          to: withChannel(channel, customerAddress),
          from: withChannel(channel, agentAddress),
          body,
        });
      },
    };
  },
  // ...continues below

withChannel re-adds the whatsapp: prefix when the channel is WhatsApp and the address doesn't already have one. Twilio needs this on outbound to ensure it routes correctly.

Wiring events to send

Finally, we need to tell eve that when the model finishes a turn, you want the completed message routed through sendMessage:

// ...continued from above
  events: {
    async "message.completed"(event, channel) {
      if (event.finishReason === "tool-calls" || !event.message) return;
      await channel.sendMessage(event.message);
    },
    async "turn.failed"(_event, channel) {
      await channel.sendMessage(
        "I hit an error while handling your request. Please try again.",
      );
    },
  },
});

And that’s it! The channel receives Orchestrator webhooks, filters echoes, dispatches turns into eve, and routes replies back on the correct channel.

Enriching prompts with Memory Store

Now that the channel handles routing, let’s use Twilio's stored knowledge of the customer to condition every model call.

The Memory Store helper

Create a new file agent/lib/memory.ts:

const BASE = "https://memory.twilio.com/v1";
export function extractE164(raw: string | undefined): string | null {
 if (!raw) return null;
 const bare = raw.startsWith("whatsapp:") ? raw.slice("whatsapp:".length) : raw;
 return /^\+\d{7,15}$/.test(bare) ? bare : null;
}
function authHeader(): string | null {
 const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN } = process.env;
 if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN) return null;
 return `Basic ${Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString("base64")}`;
}
// Hydrate the sibling identifier (phone <-> whatsapp) so the same person
// on the other channel resolves to this profile on the next interaction.
// Under GROUP_BY_PROFILE that keeps SMS and WhatsApp in one conversation.
// See the blog post [TBD] for the full walkthrough.
export async function linkCrossChannelIdentity(
 profileId: string,
 address: string,
 channel: string | undefined,
): Promise<void> {
 const auth = authHeader();
 const storeId = process.env.MEMORY_STORE_ID;
 if (!auth || !storeId) return;
 const phone = extractE164(address);
 if (!phone) return;
 const sibling = channel === "WHATSAPP"
   ? { idType: "phone", value: phone }
   : { idType: "whatsapp", value: `whatsapp:${phone}` };
 await fetch(`${BASE}/Stores/${storeId}/Profiles/${profileId}/Identifiers`, {
   method: "POST",
   headers: { Authorization: auth, "Content-Type": "application/json" },
   body: JSON.stringify(sibling),
 });
}
// Twilio treats `phone` and `whatsapp` as separate identity types, so
// Orchestrator creates the profile under whichever channel arrived first.
// Try both when looking up.
async function lookupProfileId(storeId: string, auth: string, phone: string): Promise<string | null> {
 for (const [idType, value] of [
   ["phone", phone],
   ["whatsapp", `whatsapp:${phone}`],
 ] as const) {
   const res = await fetch(`${BASE}/Stores/${storeId}/Profiles/Lookup`, {
     method: "POST",
     headers: { Authorization: auth, "Content-Type": "application/json" },
     body: JSON.stringify({ idType, value }),
   });
   if (!res.ok) continue;
   const { profiles } = (await res.json()) as { profiles?: string[] };
   if (profiles?.[0]) return profiles[0];
 }
 return null;
}
export async function fetchCustomerContext(phoneE164: string) {
 const auth = authHeader();
 const storeId = process.env.MEMORY_STORE_ID;
 if (!auth || !storeId) return null;
 const profileId = await lookupProfileId(storeId, auth, phoneE164);
 if (!profileId) return null;
 const [profileRes, obsRes] = await Promise.all([
   fetch(`${BASE}/Stores/${storeId}/Profiles/${profileId}`, { headers: { Authorization: auth } }),
   fetch(`${BASE}/Stores/${storeId}/Profiles/${profileId}/Observations?PageSize=20`, { headers: { Authorization: auth } }),
 ]);
 const profile = profileRes.ok ? await profileRes.json() : null;
 const observations = obsRes.ok ? (await obsRes.json()).observations ?? [] : [];
 return { traits: profile?.traits ?? {}, observations };
}

You use basic auth for the Memory Store REST API, a small helper for the whatsapp: prefix quirk, linkCrossChannelIdentity for the identity-linking POST you saw in the channel, and a Promise.all so the profile and its observations come back in parallel. The pageSize=20 cap is enough for a walkthrough like this.

The dynamic instruction

Now we are back in the agent/instructions/ folder from earlier.

The instructions.md contain your hand-written base prompt, and files in the instructions/* folder add to it at runtime. eve's dynamic instructions let you inject text into the system prompt at session.started or turn.started – we’ll use session.started here.

Create the instructions/customer-context.ts file now.

import { defineDynamic, defineInstructions } from "eve/instructions";
import { extractE164, fetchCustomerContext } from "../lib/memory";
export default defineDynamic({
  events: {
    "session.started": async (_event, ctx) => {
      const attrs = ctx.session.auth.initiator?.attributes as
        | { from?: string; customerAddress?: string }
        | undefined;
      const phone = extractE164(attrs?.from ?? attrs?.customerAddress);
      if (!phone) return null;
      const context = await fetchCustomerContext(phone);
      if (!context) return null;
      const lines: string[] = [];
      for (const [group, traits] of Object.entries(context.traits)) {
        for (const [key, value] of Object.entries(traits)) {
          if (value !== null && value !== undefined && value !== "") {
            lines.push(`{key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
          }
        }
      }
      if (context.observations.length > 0) {
        lines.push("recent_observations:");
        for (const obs of context.observations) lines.push(`- ${obs.content}`);
      }
      if (lines.length === 0) return null;
      return defineInstructions({
        content: ["<customer_context>", ...lines, "</customer_context>"].join("\n"),
      });
    },
  },
});

That block makes it so once per session, the model sees the encapsulated information that look like this:

<customer_context>
Contact.firstName: Marius
Contact.phone: +49XXXXXXXXXX
recent_observations:
- Asked about the return policy for the blue running shoes
- Ordered blue running shoes
- Prefers the color blue
</customer_context>

The <customer_context> tags are the formatting I picked to help the model understand what we are injecting.

Twilio's Intelligence extracted those observations automatically at the end of the previous conversation. And the best part? You didn't write a single line of extraction code.

On the very first message from a new customer, the Memory Store lookup defineDynamic returns null, so the model sees just your base instructions.md. Twilio fills in the profile after the first conversation with a customer closes, so the <customer_context> block above shows up at the start of the next conversation with that customer.

Run it

Start the agent in a new terminal (leave ngrok running):

pnpm dev

You should see something like this (though your eve version will likely differ):

☰eve v0.66.1
[DEV] server listening at http://127.0.0.1:2000/

Send an SMS to your Twilio number with some information in it, so Intelligence has material to extract later. Something like this:

"Hi, I ordered the blue running shoes last week. What's your return policy?"

You'll see the webhook fire in the eve log, the model call goes out to your provider, and replies within a second or two.

Now, send a follow-up over WhatsApp from the same phone, and deliberately leave out the details:

"Actually, can I still return them?"

"Them" is the whole test: you’re carefully revealing nothing in that second message and using a different channel. You should receive a reply on WhatsApp, and eve treats it as the same session because Orchestrator grouped both channels into one Conversation. When the conversation closes (Orchestrator's inactivity timeout, or an explicit PATCH to status: CLOSED), Conversation Intelligence extracts observations from the transcript and writes them to the Memory Store profile. From the two messages above, that's "Asked about the return policy for the blue running shoes" and "Ordered blue running shoes". And the next time this customer messages you, days, weeks, or months later? Those observations are back in the prompt on the first turn.

If nothing happens, don't panic. Try this: Open the ngrok inspector at http://127.0.0.1:4040 and look for the POST to /eve/v1/twilio-orchestrator/webhook. If it's a 401, the signature check failed (usually because PUBLIC_BASE_URL doesn't match the ngrok host Twilio actually called). If it's a 200 and no reply came, watch the pnpm dev terminal for the model call.

Peek inside the Memory Store

Pretty cool, right? But before you cheer, let’s see the memory that Twilio built for you.

Open the Twilio Console, click “Conversation Memory” under Orchestration, pick the store you provisioned (the one whose id lives in MEMORY_STORE_ID), and click the profile that was created when your phone number first messaged the agent.

The profile is split across four tabs:

  • Traits holds the structured fields (name, phone, channel identities)
  • Identifiers lists the addresses Orchestrator grouped together
  • Summaries holds the per-conversation recaps
  • Observationsare the facts Intelligence pulled from the transcript linked back to the conversation where they came from.
The Twilio Console Memory Store profile page with the Observations tab selected. A table with columns for Observation ID, Observation, Date created, and Last updated lists three rows: "Asked about the return policy for the blue running shoes", "Ordered blue running shoes", and "Prefers the color blue". Each observation ID is a link back to the source Conversation.
The Twilio Console Memory Store profile page with the Observations tab selected. A table with columns for Observation ID, Observation, Date created, and Last updated lists three rows: "Asked about the return policy for the blue running shoes", "Ordered blue running shoes", and "Prefers the color blue". Each observation ID is a link back to the source Conversation.

Figure 4: The Twilio Console Memory Store profile page showing all current observations

This is also the place to sanity-check what Intelligence has (or hasn't) extracted. If an observation doesn’t look right, you can delete it from the Console.

Note that Twilio's per-conversation timeouts are usually measured in minutes to hours (the statusTimeouts.inactive and statusTimeouts.closed fields on your Orchestrator Config), while eve's session lifetime defaults to 30 days (limits.sessionTimeoutMs). When an Orchestrator conversation closes, the customer's next message arrives with a new conversationId, which means a new eve session and a fresh transcript. This is the desired behavior. In these cases, the long-term memory moved to Twilio's Memory Store. If you want short-term memory to stretch further, raise the Orchestrator closed timeout so both systems agree on when a conversation is really over.

Where to take this from here

If you want to build further, you can do a few things once you already have the SMS and WhatsApp base running:

  • Add voice. Twilio Conversation Relay plugs into the same Orchestrator Configuration, so the same agent can answer phone calls with the same session and memory.
  • Send richer replies. Every message your agent sends today is plain text. If you want images, buttons, or list messages in a reply, define a Content Template once, and give the model a tool that references the template SID to fill in the variables. The content types overview contains a full menu of what you can send: twilio/media for images, twilio/quick-reply for buttons, twilio/list-picker for selectable lists, and a handful more.

Wrapping up with eve and Twilio Conversations

Underneath the concept of short-term and long-term memory, the architecture is fairly straightforward: one channel file, one memory helper, and one dynamic instruction. Everything else you need to make a dynamic multi-channel agent which remembers customer context across conversations and channels is already there in Twilio and eve.

You tied a well-provisioned Orchestrator Configuration, a Memory Store with automatic observation extraction, and eve's session model into a single pipeline, and built an agent that extends cleanly to voice, RCS, and any other channel Orchestrator supports, remembers customers across sessions without any bespoke persistence code, and stays swappable at the model layer.

Now it’s your turn to extend it. Build an experience we’ll all enjoy and share it in our Subreddit– we can't wait to see what you'll build.