Omnichannel AI agents: sharing long-term memory between a voice and a chat agent with Amazon Bedrock AgentCore Memory, Strands and Amplify Gen 2
DEV Community

Omnichannel AI agents: sharing long-term memory between a voice and a chat agent with Amazon Bedrock AgentCore Memory, Strands and Amplify Gen 2

TL;DR

In the first article I built semantic product search on Amazon DynamoDB Vector Search and gave that capability to an AI agent as a tool. In the second one I deployed the voice agent to Amazon Bedrock AgentCore Runtime, inside the same Amplify Gen 2 backend. So now I have two agents that do the same job, help a user shop, through two different channels: a text chat (Amplify AI Kit) and a voice agent (Strands BidiAgent using Amazon Nova Sonic). They work, but they are two strangers: tell the voice agent you are into ultralight camping gear, then open the chat and ask for a recommendation - it has no idea who you are. Each conversation starts from zero, and this article is about fixing that: giving both agents a shared memory so a preference learned in one channel shows up in the other. That is what turns "a few agents" into an omnichannel experience. I'll use Amazon Bedrock AgentCore Memory, and the key idea is deciding what the memory is keyed to. Let me walk through it.

davide-desio-eleva / dynamodbvector - An example application using DynamoDB Vector Search as an AI tool

Semantic Search for Text, Voice & Omnichannel Agents on AWS: Amazon DynamoDB Vector Search, Amplify Gen 2, Amazon AgentCore & Amazon Nova Sonic

Companion posts:

  • Your database is an AI tool: semantic search with Amazon DynamoDB Vector Search
  • Deploying a real-time voice agent with AgentCore Runtime and Amplify Gen 2
  • Omnichannel agents: sharing memory across a voice and a text agent with Amazon Bedrock AgentCore Memory (see blog/blog-3.md)

A sample application that shows how to use Amazon DynamoDB native vector search to build semantic search over application data, how to expose that capability to AI agents as a tool, how to deploy a real-time voice agent for it on Amazon Bedrock AgentCore Runtime, and how to give a voice agent and a text agent a shared memory so they behave as one omnichannel assistant - all inside a single AWS Amplify Gen 2 backend. It demonstrates the same… View on GitHub

Two kinds of memory

Before wiring anything, it helps to separate two things that both get called "memory".

  • Short-term memory is the current conversation. The turns you and the agent just exchanged, so it can follow "make it cheaper" without asking cheaper than what. It lives and dies with the session.
  • Long-term memory is what survives across sessions. Not the raw transcript, but distilled knowledge: "this customer likes ultralight gear", "their budget is around 150 euros", "they camp in winter". This is the part that makes an omnichannel experience possible, because it outlives any single conversation and any single channel.

Amazon Bedrock AgentCore Memory gives me both. I write raw events (short-term), and it runs extraction strategies in the background that distill those events into long-term records. I get to pick which strategies run:

  • User Preference extracts subjective likes and dislikes (prefers ultralight gear, budget around 150 euros).
  • Semantic extracts objective facts (bought a DayHike 25L Pack, camps in winter).

There is also a Summarization strategy, but for a shopping assistant the preferences and facts are what matter, so I'll use those two.

Why I only really need the long-term half

Here's a nice consequence of the stack I'm already on: short-term memory is basically handled for me on both channels, so the part I actually need to add is the long-term, cross-channel one.

  • On the chat side, the Amplify AI Kit already persists the conversation to Amazon DynamoDB and replays it on every turn. Following "make it cheaper" within a conversation just works - the AI Kit stores and reloads the message history automatically, no AgentCore short-term events required.
  • On the voice side, the BidiAgent keeps the live session context inside the open bidirectional stream with Nova Sonic. Within a single voice session the model already has everything it just heard, so per-session short-term memory isn't something the agent needs me to add either.

So the gap that AgentCore Memory fills here is specifically the long-term, cross-session, cross-channel one: the distilled preferences and facts that must outlive any single conversation and travel between the two agents. That's the piece neither the AI Kit nor the BidiAgent gives me on its own, and it's what the rest of this article wires up.

The one decision that matters: what is memory keyed to?

Here is the insight that makes or breaks the whole thing. AgentCore Memory organizes records under an actorId and a sessionId. The natural temptation is to let each agent use its own runtime session as the identity. If you do that, the voice agent remembers voice sessions and the chat agent remembers chat sessions, and they never meet. You would have two separate memories that happen to use the same service.

For omnichannel, the memory has to be keyed to the user, not to the runtime session or the channel.

My app already has a stable per-user identifier: the Amazon Cognito sub. The same user signs into the chat and the voice agent, so if both agents use the Cognito sub as the actorId, they read and write the same records. A preference the voice agent stored under sub=a2751... is exactly what the chat agent retrieves under sub=a2751....

So the design is: one memory store, two agents, keyed by the Cognito sub.

Step 1: Create the memory in the Amplify backend

Because Amplify Gen 2 is CDK under the hood, the memory store is just another construct in backend.ts, next to the data, auth, and the voice runtime from the previous article. I use the L1 CfnMemory: for a service this new I want what I write to map one-to-one onto the CloudFormation resource, with no abstraction deciding things for me.

import { CfnMemory } from "aws-cdk-lib/aws-bedrockagentcore";

const agentMemory = new CfnMemory(voiceStack, "ShoppingAgentMemory", {
  name: "shoppingAgentMemory",
  // Raw short-term events are kept for 30 days before expiring.
  eventExpiryDuration: 30,
  memoryExecutionRoleArn: memoryExecutionRole.roleArn,
  memoryStrategies: [
    {
      userPreferenceMemoryStrategy: {
        name: "PreferenceLearner",
        namespaces: ["/preferences/{actorId}/"],
      },
    },
    {
      semanticMemoryStrategy: {
        name: "FactExtractor",
        namespaces: ["/facts/{actorId}/"],
      },
    },
  ],
});

const memoryId = agentMemory.attrMemoryId;

Two things worth calling out:

  • The namespaces use a {actorId} template. AgentCore substitutes the real actorId at write and read time, so /preferences/{actorId}/ becomes /preferences/a2751.../ for that user. This is what physically separates one user's memories from another's, using the same key both agents share.
  • The memoryExecutionRoleArn matters because long-term extraction runs Amazon Bedrock models on your behalf. The built-in strategies read your raw events and call a model to distill them, so the memory needs a role allowed to invoke Bedrock:
const memoryExecutionRole = new iam.Role(voiceStack, "AgentMemoryRole", {
  assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com", {
    conditions: {
      StringEquals: {
        "aws:SourceAccount": account,
      },
    },
  }),
});
memoryExecutionRole.addToPolicy(
  new iam.PolicyStatement({
    actions: ["bedrock:InvokeModel"],
    resources: ["arn:aws:bedrock:*::foundation-model/*"],
  })
);

Then both the voice runtime role and the chat handler role get read/write access to the memory (CreateEvent, RetrieveMemoryRecords, ListMemoryRecords, and friends) on agentMemory.attrMemoryArn, and both get MEMORY_ID as an environment variable. Same store, same permissions, two consumers.

Step 2: The voice agent

The voice agent is a Strands BidiAgent. The first job is to make sure it keys memory to the Cognito sub, not to the runtime session. The frontend already authenticates the WebSocket to AgentCore with the user's Cognito token (that was the whole point of the JWT authorizer in the previous article). The token is a JWT, and the sub is right there inside it. So I resolve the actorId from the connection:

def resolve_actor_id(websocket: WebSocket) -> str:
    """The memory actorId is the Cognito `sub`, shared with the chat agent."""
    headers = websocket.headers
    auth = headers.get("authorization")
    if auth:
        token = auth[7:] if auth.lower().startswith("bearer ") else auth
        sub = _decode_jwt_sub(token)  # base64url-decode the JWT payload, read `sub`
        if sub:
            return sub
    custom = headers.get("x-amzn-bedrock-agentcore-runtime-custom-actorid")
    if custom:
        return custom
    return "anonymous"

Now, a browser can't set arbitrary headers on a WebSocket handshake, and AgentCore only forwards headers to your container if they are on an allowlist. So I let the frontend pass the sub as a custom runtime header via a query parameter, and I allowlist it on the runtime:

// backend.ts - on the CfnRuntime requestHeaderConfiguration:
{
  requestHeaderAllowlist: [
    "X-Amzn-Bedrock-AgentCore-Runtime-Custom-actorId",
  ],
},
// frontend - the Cognito sub, passed as a custom runtime header
const actorId = session.tokens?.idToken?.payload?.sub;
url += `&X-Amzn-Bedrock-AgentCore-Runtime-Custom-actorId=${encodeURIComponent(actorId)}`;

Values sent as X-Amzn-Bedrock-AgentCore-Runtime-Custom-* are delivered to the container as headers of the same name, and resolve_actor_id reads it. Now the voice agent and the chat agent agree on who the user is.

Writing memory: the native session manager

For persistence, Strands and bedrock-agentcore offer a native integration: a session manager that transparently writes every turn to AgentCore Memory. I hand it the memory id, the session id, and, crucially, the shared actorId:

from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig
from bedrock_agentcore.memory.integrations.strands.session_manager import (
    AgentCoreMemorySessionManager,
)

memory_config = AgentCoreMemoryConfig(
    memory_id=MEMORY_ID,
    session_id=session_id,   # unique per conversation
    actor_id=actor_id,       # the Cognito sub - shared across channels
)

session_manager = AgentCoreMemorySessionManager(
    agentcore_memory_config=memory_config,
    region_name=MEMORY_REGION,
)

voice_agent = BidiAgent(
    model=sonic_model,
    tools=[search_products, stop_conversation],
    system_prompt=build_system_prompt(actor_id),  # more on this in a second
    session_manager=session_manager,
)

With the session manager attached, every turn of the conversation gets written to the memory store, and the background strategies distill preferences and facts from those turns. Writing is fully handled for me.

Reading memory: do it yourself

Reading back is where it gets interesting, and where the two agents end up looking different. The native session manager's automatic retrieval applies to the standard Agent, not to the streaming BidiAgent that Nova Sonic uses. For a real-time voice agent, retrieval is not wired into the loop for you. So I retrieve the long-term records myself, at the start of the session, and inject them into the system prompt:

def retrieve_memories(actor_id: str) -> list[str]:
    """Fetch this user's long-term preferences and facts, keyed by Cognito sub."""
    namespaces = [f"/preferences/{actor_id}/", f"/facts/{actor_id}/"]
    context = []
    for namespace in namespaces:
        records = memory_client.retrieve_memories(
            memory_id=MEMORY_ID,
            namespace_path=namespace,
            query="user preferences, interests and facts",
            top_k=5,
        )
        for record in records:
            text = record.get("content", {}).get("text", "").strip()
            if text:
                context.append(text)
    return context


def build_system_prompt(actor_id: str) -> str:
    context = retrieve_memories(actor_id)
    if not context:
        return SYSTEM_PROMPT
    remembered = "\n".join(f"- {item}" for item in context)
    return (
        f"{SYSTEM_PROMPT}\n\n"
        "Here is what you remember about this customer from previous "
        "conversations, across both voice and chat. Use it to personalize your "
        "suggestions, and confirm before assuming it still applies:\n"
        f"{remembered}"
    )

So on the voice side: the session manager writes, and I read. The write is native, the read is manual.

Step 3: The text chat agent

The chat agent runs on the Amplify AI Kit, through a custom conversation handler. There is no magic session manager here either, so the pattern is symmetric with the voice agent's read path: I do the retrieve-and-inject myself, plus I persist the turn. The AI Kit passes the user's Cognito token on the conversation event headers, so I get the same sub the voice agent uses:

function resolveActorId(event: ConversationTurnEvent): string | undefined {
  const auth = event.request.headers["authorization"];
  return decodeJwtSub(auth); // same base64url-decode → `sub`
}

Then the handler wraps the default AI Kit handler. Before the model runs, it retrieves the same namespaces and prepends what it finds to the system prompt. After, it writes the user's turn so the strategies can extract from it:

export const handler = async (event: ConversationTurnEvent) => {
  const actorId = resolveActorId(event);
  if (memoryClient && MEMORY_ID && actorId) {
    const userText = await getLatestUserText(event);
    const [preferences, facts] = await Promise.all([
      retrieveMemory(actorId, "/preferences", userText),
      retrieveMemory(actorId, "/facts", userText),
    ]);
    const preamble = buildMemoryPreamble(preferences, facts);
    if (preamble) {
      event.modelConfiguration.systemPrompt = `${preamble}\n\n${event.modelConfiguration.systemPrompt}`;
    }
    if (userText) {
      await persistUserTurn(actorId, event.conversationId, userText);
    }
  }
  return handleConversationTurnEvent(event);
};

Same store, same actorId, same namespaces. The only difference from the voice agent is that here I also write manually (persistUserTurn calls CreateEvent), because there is no session manager doing it for me.

Two integration styles, one memory

This is

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.