{"slug": "omnichannel-ai-agents-sharing-long-term-memory-between-a-voice-and-a-chat-agent", "title": "Omnichannel AI agents: sharing long-term memory between a voice and a chat agent with Amazon Bedrock AgentCore Memory, Strands and Amplify Gen 2", "summary": "A developer built an omnichannel shopping assistant that shares long-term memory between a text chat agent and a real-time voice agent using Amazon Bedrock AgentCore Memory, Strands, and Amplify Gen 2. The system uses AgentCore Memory extraction strategies to distill raw conversation events into persistent preference and fact records, so a preference learned through one channel carries over to the other. The voice agent runs on Amazon Nova Sonic via Strands' BidiAgent, while the chat agent uses Amplify AI Kit with DynamoDB-backed history.", "body_md": "In the [first article](https://dev.to/aws-builders/your-database-is-an-ai-tool-semantic-search-with-amazon-dynamodb-vector-search-46ff) 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](https://dev.to/aws-builders/deploying-a-real-time-voice-agent-with-agentcore-runtime-and-amplify-gen-2-45bl) I deployed the voice agent to `Amazon Bedrock AgentCore Runtime`, inside the same `Amplify Gen 2` backend.\n\nSo 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`).\n\nThey 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.\n\n**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.** \n\nThat is what turns \"a few agents\" into an omnichannel experience.\n\nI'll use **Amazon Bedrock AgentCore Memory**, and the key idea is deciding what the memory is keyed to. Let me walk through it.\n\nCompanion posts:\n\n[Your database is an AI tool: semantic search with Amazon DynamoDB Vector Search](https://dev.to/aws-builders/your-database-is-an-ai-tool-semantic-search-with-amazon-dynamodb-vector-search-46ff)\n[Deploying a real-time voice agent with AgentCore Runtime and Amplify Gen 2](https://dev.to/aws-builders/deploying-a-real-time-voice-agent-with-agentcore-runtime-and-amplify-gen-2-45bl)- Omnichannel agents: sharing memory across a voice and a text agent with Amazon Bedrock AgentCore Memory (see\n[`blog/blog-3.md`](https://github.com/davide-desio-eleva/dynamodbvector/./blog/blog-3.md))\n\nA 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.\n\nIt demonstrates the same…\n\nBefore wiring anything, it helps to separate two things that both get called \"memory\".\n\n**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.\n\n**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.\n\n`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:\n\n`prefers ultralight gear`, `budget around 150 euros`).` bought a DayHike 25L Pack`, `camps in winter`).\nThere is also a Summarization strategy, but for a shopping assistant the preferences and facts are what matter, so I'll use those two.\n\nHere'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.**\n\nOn 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.\n\nOn 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.\n\nSo 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.\n\nHere is the insight that makes or breaks the whole thing.\n\nAgentCore 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.\n\n**For omnichannel, the memory has to be keyed to the *user*, not to the runtime session or the channel.**\n\nMy 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...`.\n\nSo the design is one memory store, two agents, keyed by the Cognito `sub`:\n\nBecause `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.\n\n``` js\nimport { CfnMemory } from \"aws-cdk-lib/aws-bedrockagentcore\";\n\nconst agentMemory = new CfnMemory(voiceStack, \"ShoppingAgentMemory\", {\n  name: \"shoppingAgentMemory\",\n  // Raw short-term events are kept for 30 days before expiring.\n  eventExpiryDuration: 30,\n  memoryExecutionRoleArn: memoryExecutionRole.roleArn,\n  memoryStrategies: [\n    {\n      userPreferenceMemoryStrategy: {\n        name: \"PreferenceLearner\",\n        namespaces: [\"/preferences/{actorId}/\"],\n      },\n    },\n    {\n      semanticMemoryStrategy: {\n        name: \"FactExtractor\",\n        namespaces: [\"/facts/{actorId}/\"],\n      },\n    },\n  ],\n});\n\nconst memoryId = agentMemory.attrMemoryId;\n```\n\nTwo things worth calling out.\n\nThe `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.\n\nThe `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:\n\n``` js\nconst memoryExecutionRole = new iam.Role(voiceStack, \"AgentMemoryRole\", {\n  assumedBy: new iam.ServicePrincipal(\"bedrock-agentcore.amazonaws.com\", {\n    conditions: { StringEquals: { \"aws:SourceAccount\": account } },\n  }),\n});\nmemoryExecutionRole.addToPolicy(new iam.PolicyStatement({\n  actions: [\"bedrock:InvokeModel\"],\n  resources: [\"arn:aws:bedrock:*::foundation-model/*\"],\n}));\n```\n\nThen 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.\n\nThe 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.\n\nThe 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:\n\n``` php\ndef resolve_actor_id(websocket: WebSocket) -> str:\n    \"\"\"The memory actorId is the Cognito `sub`, shared with the chat agent.\"\"\"\n    headers = websocket.headers\n    auth = headers.get(\"authorization\")\n    if auth:\n        token = auth[7:] if auth.lower().startswith(\"bearer \") else auth\n        sub = _decode_jwt_sub(token)  # base64url-decode the JWT payload, read `sub`\n        if sub:\n            return sub\n    custom = headers.get(\"x-amzn-bedrock-agentcore-runtime-custom-actorid\")\n    if custom:\n        return custom\n    return \"anonymous\"\n```\n\nNow, 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:\n\n```\n// backend.ts — on the CfnRuntime\nrequestHeaderConfiguration: {\n  requestHeaderAllowlist: [\"X-Amzn-Bedrock-AgentCore-Runtime-Custom-actorId\"],\n},\njs\n// frontend — the Cognito sub, passed as a custom runtime header\nconst actorId = session.tokens?.idToken?.payload?.sub;\nurl += `&X-Amzn-Bedrock-AgentCore-Runtime-Custom-actorId=${encodeURIComponent(actorId)}`;\n```\n\nValues 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.\n\nFor 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`:\n\n``` python\nfrom bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig\nfrom bedrock_agentcore.memory.integrations.strands.session_manager import (\n    AgentCoreMemorySessionManager,\n)\n\nmemory_config = AgentCoreMemoryConfig(\n    memory_id=MEMORY_ID,\n    session_id=session_id,      # unique per conversation\n    actor_id=actor_id,          # the Cognito sub — shared across channels\n)\nsession_manager = AgentCoreMemorySessionManager(\n    agentcore_memory_config=memory_config,\n    region_name=MEMORY_REGION,\n)\n\nvoice_agent = BidiAgent(\n    model=sonic_model,\n    tools=[search_products, stop_conversation],\n    system_prompt=build_system_prompt(actor_id),   # more on this in a second\n    session_manager=session_manager,\n)\n```\n\nWith 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.\n\nReading back is where it gets interesting, and where the two agents end up looking different.\n\nThe 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:\n\n``` php\ndef retrieve_memories(actor_id: str) -> list[str]:\n    \"\"\"Fetch this user's long-term preferences and facts, keyed by Cognito sub.\"\"\"\n    namespaces = [f\"/preferences/{actor_id}/\", f\"/facts/{actor_id}/\"]\n    context = []\n    for namespace in namespaces:\n        records = memory_client.retrieve_memories(\n            memory_id=MEMORY_ID,\n            namespace_path=namespace,\n            query=\"user preferences, interests and facts\",\n            top_k=5,\n        )\n        for record in records:\n            text = record.get(\"content\", {}).get(\"text\", \"\").strip()\n            if text:\n                context.append(text)\n    return context\n\ndef build_system_prompt(actor_id: str) -> str:\n    context = retrieve_memories(actor_id)\n    if not context:\n        return SYSTEM_PROMPT\n    remembered = \"\\n\".join(f\"- {item}\" for item in context)\n    return (\n        f\"{SYSTEM_PROMPT}\\n\\n\"\n        \"Here is what you remember about this customer from previous \"\n        \"conversations, across both voice and chat. Use it to personalize your \"\n        \"suggestions, and confirm before assuming it still applies:\\n\"\n        f\"{remembered}\"\n    )\n```\n\nSo on the voice side: **the session manager writes, and I read.** The write is native, the read is manual.\n\nThe 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.\n\nThe AI Kit passes the user's Cognito token on the conversation event headers, so I get the same `sub` the voice agent uses:\n\n```\nfunction resolveActorId(event: ConversationTurnEvent): string | undefined {\n  const auth = event.request.headers[\"authorization\"];\n  return decodeJwtSub(auth); // same base64url-decode → `sub`\n}\n```\n\nThen 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:\n\n``` js\nexport const handler = async (event: ConversationTurnEvent) => {\n  const actorId = resolveActorId(event);\n\n  if (memoryClient && MEMORY_ID && actorId) {\n    const userText = await getLatestUserText(event);\n\n    const [preferences, facts] = await Promise.all([\n      retrieveMemory(actorId, \"/preferences\", userText),\n      retrieveMemory(actorId, \"/facts\", userText),\n    ]);\n\n    const preamble = buildMemoryPreamble(preferences, facts);\n    if (preamble) {\n      event.modelConfiguration.systemPrompt =\n        `${preamble}\\n\\n${event.modelConfiguration.systemPrompt}`;\n    }\n\n    if (userText) {\n      await persistUserTurn(actorId, event.conversationId, userText);\n    }\n  }\n\n  return handleConversationTurnEvent(event);\n};\n```\n\nSame 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.\n\nThis is the part I find genuinely interesting. The two agents talk to the *same* memory but integrate with it differently, and that is not a mistake, it's the reality of working across two runtimes:\n\n|  | Voice agent (Strands BidiAgent) | Chat agent (Amplify AI Kit) | \n|---|---|---|\n| **Write** | Native session manager | Manual `CreateEvent` | \n| **Read** | Manual retrieve + inject into system prompt | Manual retrieve + inject into system prompt | \n| **Identity** | Cognito `sub` from JWT / custom header | Cognito `sub` from JWT | \n\nThe takeaway: **omnichannel memory is not about a single SDK that does everything for you. It's about agreeing on the key (the user identity) and the namespaces.** Once both agents agree that memory is keyed to the Cognito `sub` and lives under `/preferences/{actorId}/` and `/facts/{actorId}/`, the plumbing on each side can differ. The memory is the contract; the integration is per-runtime.\n\nThere was another perfectly valid way to do this, and it's worth naming.\n\nInstead of wiring each agent to AgentCore Memory through its own runtime integration, I could have built a **single \"memory\" tool**, a small function that reads and writes AgentCore Memory, and handed that same tool to every agent, exactly like `searchProducts` is shared today. Every agent would then remember and recall by calling the tool, the integration would be identical everywhere, and a third or fourth channel would just get the same tool. That approach is clean, uniform, and it's probably what I'd reach for if I had five channels instead of two.\n\nI chose the other path on purpose: I wanted to explore the **native integration options** each runtime offers, the Strands session manager on the voice side, and the Amplify AI Kit conversation handler on the chat side, and see how memory fits into each one's grain rather than bolting a uniform tool on top. That's also what surfaced the interesting asymmetry above (native write, manual read for `BidiAgent`), which the shared-tool approach would have hidden.\n\nBut the difference between the two isn't just uniformity, it's **who decides when memory is used**, and that's the part I find most important.\n\nWith a memory **tool**, recall is *agentic*: the memory is one more tool in the agent's belt, and the LLM decides, turn by turn, whether to call it. That's flexible (the agent can choose to look something up only when it seems relevant) but it's also non-deterministic. The model might not call the tool when you'd want it to, so the user says \"give me options\" and the agent, having decided it didn't need memory this turn, answers as if it knows nothing about them. You're trusting the model's judgment about when to remember.\n\nWith the **native** integration I used here, recall is *deterministic*. I retrieve the user's preferences and inject them into the system prompt at the start of every conversation, unconditionally. The model doesn't get a vote on whether to be aware of them; the context is simply always there. For a shopping assistant that should feel like it *knows* the returning customer, \"always aware\" is the behavior I want, not \"aware if the model felt like calling a tool\".\n\nSo the trade-off is: a memory tool gives the LLM control and flexibility over recall; native injection gives *you* control and guarantees the context is present. Neither is universally right. Agentic recall shines when memory is large and lookups should be selective; deterministic injection shines when a small, high-value profile should shape every single response.\n\nSo read this article as one of two good options. If you want maximum uniformity across many agents and you're comfortable letting the model decide when to recall, a shared memory tool is a great choice. If you want the context guaranteed on every turn and you want to understand how memory plugs into Strands and Amplify Gen 2 natively, this is that exploration. Either way, the design principle that matters, keying memory to the user, is the same.\n\nThe test that matters is the bidirectional one.\n\n**Chat, then voice.** In the text chat I say I'm shopping for camping and I pick a DayHike 25L Pack. A minute later (long-term extraction is asynchronous, it takes a moment), I open the voice agent and ask, in Italian, what it recommends for me. It brings up camping and the pack, without me repeating anything. It read what the chat agent wrote.\n\nHere is me asking via chat articles for un upcoming hiking in October in Iceland: the agent suggested me some useful ones.\n\nAfter that I've made a call to the voice agent, asking more information about those article. I've never mentioned Iceland again, thus confirming it got this information from the memory (also I have logs!).\n\n**Voice, then chat.** The reverse works the same way. A preference spoken to the voice agent surfaces in the next chat turn.\n\nOne thing to keep in mind when you try this: long-term memory is extracted **asynchronously**. Right after a turn, the raw event exists but the distilled preference might not yet, so a retrieve one second later can come back empty. Give the extraction a moment. That is the nature of long-term memory: it's the slow, considered kind, not the immediate transcript.\n\nAgentCore Memory is **serverless and consumption-based**, there is no fixed monthly fee just for having a memory store. You pay on three axes: short-term events written, long-term records stored, and retrieval calls. For a demo like this it rounds to cents.\n\nThe nice part is that the rest of the stack is the same kind of thing. The AgentCore **Runtime** (in the serverless microVM mode we use) bills CPU and memory only while a session is running, I/O wait is free, so with no one talking to it there is effectively no idle compute charge. The **ECR** image is just storage, a few cents per month. Left alone, this whole stack costs almost nothing; you pay when someone actually uses it.\n\nHere is where the design pays off. Once memory is keyed to the user and not to the channel, **adding a third channel is mostly plumbing.** The memory doesn't change at all.\n\nImagine a WhatsApp channel using **AWS End User Messaging** (Social). The shape would be:\n\n`Amazon SNS`) to a Lambda.` searchProducts` tool the other two channels use.`sub`. So you need a mapping from phone number to your app's user identity, for example a small `DynamoDB` table populated during an opt-in or account-linking step. Once you resolve the phone number to the Cognito `actorId`.`/preferences/{actorId}/` and `/facts/{actorId}/`, inject them into the prompt, generate a reply, send it back through The user starts on WhatsApp on the train, continues by voice at home, finishes in the web chat, and the assistant remembers throughout. No channel owns the memory. The **user** owns the memory, and every channel is just a different door into the same context.\n\nThat mapping step (phone number to user identity) is the only real new work. Everything else, the memory store, the namespaces, the retrieve-and-inject pattern, is already built. That is the point of keying memory to the user: **new channels are additive, not a rewrite.**\n\nThree things stood out building this.\n\n**Identity is the design.** The single most important decision wasn't which memory strategy to use or how to call the SDK. It was keying memory to the Cognito `sub` instead of the runtime session. Get that right and omnichannel falls out almost for free. Get it wrong and you have two agents with amnesia and no amount of SDK cleverness fixes it.\n\n**One memory, many integrations.** The voice agent and the chat agent integrate with AgentCore Memory differently, one uses a native session manager to write, the other writes manually, and both retrieve and inject by hand. That asymmetry is fine. The memory is the shared contract; how each runtime reads and writes it is a local detail.\n\n**It's all one backend, still.** The memory store, its IAM, its wiring into both the voice runtime and the chat handler, are all just `CDK` constructs sitting next to the data and auth. Adding cross-channel memory didn't mean a new system to operate. It meant a few more constructs in the same `npx ampx sandbox` deploy.\n\n**Your `Amazon DynamoDB` database was an AI tool. The agent that talks to it became serverless. And now, whichever channel you reach for, it's the same assistant, and it remembers you.**\n\nI'm [D. De Sio](https://www.linkedin.com/in/desiodavide) and I work as a Head of Software Engineering in [Eleva](https://eleva.it/).\n\nAs of September 2026, I’m an [AWS Certified Solution Architect Professional](https://www.credly.com/badges/9929fdf2-7a3d-4013-9de6-57c80e4920b9/public_url) and [AWS Certified DevOps Engineer Professional](https://www.credly.com/badges/8c5a1487-191b-429e-8c2d-7cee43bf316b/public_url), but also a [User Group Leader (in Pavia)](https://www.linkedin.com/company/aws-user-group-pavia/), an **AWS Community Builder** and, last but not least, a #serverless enthusiast.\n\nI just shared long-term memory with my AI agents. I'm sure they'll remember I'm the good guy when Skynet goes live.\n\nThe full agenda for [AWS Community Day Italy](https://www.awscommunityday.it/) is out!\n\nIf you'd love to hear what the community has been working on, what they've learned, and what they want to share, come join us in Rome on October 2nd.", "url": "https://wpnews.pro/news/omnichannel-ai-agents-sharing-long-term-memory-between-a-voice-and-a-chat-agent", "canonical_source": "https://dev.to/aws-builders/omnichannel-ai-agents-sharing-long-term-memory-between-a-voice-and-a-chat-agent-with-amazon-4003", "published_at": "2026-09-21 07:10:29+00:00", "updated_at": "2026-09-21 07:23:22.470155+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "ai-tools", "developer-tools", "mlops"], "entities": ["Amazon Bedrock AgentCore Memory", "Amazon Web Services", "Strands", "Amplify Gen 2", "Amazon DynamoDB", "Amazon Nova Sonic", "BidiAgent", "Amplify AI Kit"], "alternates": {"html": "https://wpnews.pro/news/omnichannel-ai-agents-sharing-long-term-memory-between-a-voice-and-a-chat-agent", "markdown": "https://wpnews.pro/news/omnichannel-ai-agents-sharing-long-term-memory-between-a-voice-and-a-chat-agent.md", "text": "https://wpnews.pro/news/omnichannel-ai-agents-sharing-long-term-memory-between-a-voice-and-a-chat-agent.txt", "jsonld": "https://wpnews.pro/news/omnichannel-ai-agents-sharing-long-term-memory-between-a-voice-and-a-chat-agent.jsonld"}}