{"slug": "deploying-a-real-time-voice-agent-with-agentcore-runtime-and-amplify-gen-2", "title": "Deploying a real-time voice agent with AgentCore Runtime and Amplify Gen 2", "summary": "A developer demonstrated deploying a real-time voice agent on Amazon Bedrock AgentCore Runtime within an existing AWS Amplify Gen 2 backend. The agent, built with a Strands BidiAgent powered by Amazon Nova Sonic and a DynamoDB Vector Search product tool, runs as an ARM64 container listening on port 8080 with a WebSocket at /ws, reusing the app's existing authentication and deployment pipeline. The writeup follows an earlier project that exposed semantic product search to AI agents as a tool.", "body_md": "In the [previous article](https://dev.to/aws-builders/your-database-is-an-ai-tool-semantic-search-with-amazon-dynamodb-vector-search-46ff) I built a semantic product search on top of `Amazon DynamoDB` Vector Search, and then gave that capability to an AI agent as a tool. One of the things I explored at the end was a voice agent: a Strands `BidiAgent` powered by `Amazon Nova Sonic` that could search the catalog by voice.\n\nThat voice agent ran locally. A Python server on my laptop, a WebSocket, my microphone. Great for a demo, but it lives on my machine.\n\n**So the question for this article is: how do I actually deploy it?**\n\nI want the voice agent to run on AWS, I want it authenticated with the same users my app already has, and I want it to be part of the same `Amplify Gen 2` backend as everything else. No separate project, no separate auth, no separate deploy command.\n\nIt turns out this fits together really nicely with **Amazon Bedrock AgentCore Runtime**. Let me walk through it.\n\nCompanion posts:\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, and how to deploy a real-time voice agent for it on **Amazon Bedrock AgentCore Runtime** — all inside a single AWS Amplify Gen 2 backend.\n\nIt demonstrates the same idea through three interfaces:\n\nQuick recap of the voice agent: it's a Python app: a FastAPI server that exposes a WebSocket on `/ws`, and a Strands `BidiAgent` wired to `Amazon Nova Sonic` with our `search_products` tool.\n\n``` python\nfrom fastapi import FastAPI, WebSocket\nfrom strands.experimental.bidi import BidiAgent\nfrom strands.experimental.bidi.models import BidiNovaSonicModel\nfrom strands import tool\n\n@tool\ndef search_products(query: str) -> str:\n    \"\"\"Search the product catalog using natural language.\"\"\"\n    # Nova Micro parses the query, Titan embeds it,\n    # DynamoDB SearchVectors finds the matches, we filter by price.\n    ...\n\nsonic_model = BidiNovaSonicModel(model_id=\"amazon.nova-2-sonic-v1:0\", ...)\n\napp = FastAPI()\n\n@app.websocket(\"/ws\")\nasync def voice_chat(websocket: WebSocket):\n    agent = BidiAgent(model=sonic_model, tools=[search_products], ...)\n    await websocket.accept()\n    await agent.run(\n        inputs=[websocket.receive_json],\n        outputs=[websocket.send_json],\n    )\n```\n\nLocally I ran this with `uvicorn`, the browser connected to `ws://127.0.0.1:8080/ws`, and everything worked. \n\n**Now I want the exact same code running on AWS.**\n\n`Amazon Bedrock AgentCore Runtime` is a serverless runtime purpose-built for hosting AI agents. It's framework-agnostic (Strands, LangGraph, CrewAI, whatever) and, importantly for us, it supports **bidirectional streaming over WebSocket**, which is exactly what a real-time voice agent needs.\n\n`Amazon Bedrock AgentCore Runtime` contract is simple, you give it a container that listens on **port 8080** and exposes a WebSocket at **`/ws`**, plus a `/ping` health check. That's already how our agent is written. \n\n`Amazon Bedrock AgentCore`  handles the rest: session isolation, scaling, authentication, and the public WebSocket endpoint.\n\nSo the plan is:\n\nAnd because **`Amplify Gen 2` is CDK under the hood**, I can do all of this inside the same `amplify/backend.ts` I already have, without a second project or a specific `CDK`/` CloudFormation`/Terraform project.\n\n`Amazon Bedrock AgentCore Runtime` runs **ARM64** containers. \n\nThe Dockerfile is minimal:\n\n```\nFROM --platform=linux/arm64 python:3.12-slim\n\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY agent.py .\n\nENV CONTAINER_ENV=true\nEXPOSE 8080\nCMD [\"python\", \"agent.py\"]\n```\n\nThe only change to the agent itself is binding to `0.0.0.0` when running in the container (locally it stayed on `127.0.0.1`):\n\n```\nhost = \"0.0.0.0\" if os.getenv(\"CONTAINER_ENV\") else \"127.0.0.1\"\nuvicorn.run(app, host=host, port=8080)\n```\n\nHere's the part I like: again, since `Amplify Gen 2` backends are `CDK` constructs, I can build the image and create the runtime right in my `backend.ts`.\n\nFirst, we should build the ARM64 image and push it to `Amazon ECR`. The `CDK` `DockerImageAsset` does all of that during deployment, no manual `docker build` or `docker push`:\n\n``` js\nimport * as ecrAssets from \"aws-cdk-lib/aws-ecr-assets\";\n\nconst voiceImage = new ecrAssets.DockerImageAsset(voiceStack, \"VoiceAgentImage\", {\n  directory: path.join(__dirname, \"..\", \"voice-agent\"),\n  platform: ecrAssets.Platform.LINUX_ARM64,\n});\n```\n\nThen we need an execution role for the runtime: it needs to pull the image, call `Amazon Bedrock` (`Amazon Nova Sonic 2` for voice, plus `Amazon Nova Micro` and `Amazon Titan Embeddings` for the search tool), and run `SearchVectors` on the DynamoDB table:\n\n``` js\nconst voiceRuntimeRole = new iam.Role(voiceStack, \"VoiceAgentRuntimeRole\", {\n  assumedBy: new iam.ServicePrincipal(\"bedrock-agentcore.amazonaws.com\", {\n    conditions: { StringEquals: { \"aws:SourceAccount\": account } },\n  }),\n});\n\nvoiceImage.repository.grantPull(voiceRuntimeRole);\nvoiceRuntimeRole.addToPolicy(new iam.PolicyStatement({\n  actions: [\"ecr:GetAuthorizationToken\"],\n  resources: [\"*\"],\n}));\n\nvoiceRuntimeRole.addToPolicy(new iam.PolicyStatement({\n  actions: [\n    \"bedrock:InvokeModel\",\n    \"bedrock:InvokeModelWithBidirectionalStream\",\n  ],\n  resources: [\n    \"arn:aws:bedrock:*::foundation-model/amazon.nova-2-sonic-v1:0\",\n    \"arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v2:0\",\n    \"arn:aws:bedrock:*::foundation-model/amazon.nova-micro-v1:0\",\n    `arn:aws:bedrock:*:${account}:inference-profile/eu.amazon.nova-micro-v1:0`,\n  ],\n}));\n\nvoiceRuntimeRole.addToPolicy(new iam.PolicyStatement({\n  actions: [\"dynamodb:SearchVectors\", \"dynamodb:GetItem\"],\n  resources: [productsTable.tableArn, `${productsTable.tableArn}/index/*`],\n}));\n```\n\nAnd finally we need the runtime itself. Here I deliberately reach for the L1 `CfnRuntime` construct. Level 1 (L1) constructs map directly one-to-one to raw CloudFormation resources, while Level 2 (L2) constructs provide higher-level, object-oriented abstractions with built-in security best practices and helper methods. For a service this new I prefer the L1: it maps straight onto the `CloudFormation` resource, so what I write is exactly what gets deployed, with no abstraction deciding things for me.\n\n``` js\nimport { CfnRuntime } from \"aws-cdk-lib/aws-bedrockagentcore\";\n\nconst voiceRuntime = new CfnRuntime(voiceStack, \"VoiceAgentRuntime\", {\n  agentRuntimeName: \"voiceShoppingAgent\",\n  agentRuntimeArtifact: {\n    containerConfiguration: { containerUri: voiceImage.imageUri },\n  },\n  networkConfiguration: { networkMode: \"PUBLIC\" },\n  protocolConfiguration: \"HTTP\",\n  roleArn: voiceRuntimeRole.roleArn,\n  environmentVariables: {\n    CONTAINER_ENV: \"true\",\n    TABLE_NAME: productsTable.tableName,\n    BEDROCK_REGION: \"eu-north-1\",\n    // ...model ids\n  },\n  authorizerConfiguration: {\n    customJwtAuthorizer: {\n      discoveryUrl,\n      allowedClients: [userPoolClient.userPoolClientId],\n    },\n  },\n});\n```\n\nThat `protocolConfiguration: \"HTTP\"` is worth a note. The valid protocol values are `HTTP`, `A2A`, `AGUI` and `MCP`, there is no `WEBSOCKET` value. Bidirectional streaming over WebSocket runs **on top of the `HTTP` server protocol**: the container exposes `/ws`, AgentCore speaks WebSocket to the client, but as far as the runtime configuration is concerned, the protocol is `HTTP`.\n\nOne detail on the execution role: alongside `grantPull()` (which covers `BatchGetImage` and `GetDownloadUrlForLayer` on the repository) the role also needs `ecr:GetAuthorizationToken`, and that action requires a `\"*\"` resource. Both are in the role above so the runtime can pull the image.\n\nThis is where the \"same backend\" idea pays off as ny app already has authentication: `Amplify` created a `Amazon Cognito` user pool and users sign in to use the chat and search. I don't want a second identity system for the voice agent.\n\n`AgentCore Runtime` supports **JWT inbound authorization**. You point it at an OIDC discovery URL and a list of allowed clients. An `Amazon Cognito` user pool is also an OIDC provider, so I can wire the runtime straight to it:\n\n``` js\nconst userPool = backend.auth.resources.userPool;\nconst userPoolClient = backend.auth.resources.userPoolClient;\n\nconst discoveryUrl =\n  `https://cognito-idp.${region}.amazonaws.com/` +\n  `${userPool.userPoolId}/.well-known/openid-configuration`;\n\n// ...passed into the runtime's authorizerConfiguration.customJwtAuthorizer\n```\n\nNow the same user who is signed into the app can authenticate to the voice agent, with no extra setup. The token they already have is the token the runtime accepts.\n\nThe signed-in user connects to the runtime, so the `Amazon Cognito` **authenticated role** needs permission to invoke it. There's a nice detail in how you wire this up: the voice stack already depends on the auth stack (it reads the user pool), so you want the dependency to stay one-directional. The clean way is to define the policy **inside the voice stack** and attach it to the existing auth role by reference:\n\n```\nnew iam.Policy(voiceStack, \"VoiceAgentInvokePolicy\", {\n  roles: [backend.auth.resources.authenticatedUserIamRole],\n  statements: [\n    new iam.PolicyStatement({\n      actions: [\n        \"bedrock-agentcore:InvokeAgentRuntime\",\n        \"bedrock-agentcore:InvokeAgentRuntimeWithWebSocketStream\",\n      ],\n      resources: [\n        voiceRuntime.attrAgentRuntimeArn,\n        `${voiceRuntime.attrAgentRuntimeArn}/*`,\n      ],\n    }),\n  ],\n});\n```\n\nThe policy is created in the voice stack, which is allowed to reference the auth role, while the auth stack never needs to know about the voice stack. The dependency flows in a single direction.\n\nThe last piece is the frontend. Locally the browser connected to `ws://127.0.0.1:8080/ws`. Deployed, it connects to the `AgentCore` endpoint:\n\n```\nwss://bedrock-agentcore.<region>.amazonaws.com/runtimes/<runtimeArn>/ws\n```\n\nThe interesting question is authentication. `AgentCore` accepts `SigV4` (signed headers or a presigned URL) or an OAuth bearer token. From a **browser**, `SigV4` on a WebSocket is awkward, because the browser's WebSocket API doesn't let you set custom headers on the handshake.\n\nAWS documents a clean workaround for exactly this case: pass the bearer token through the `Sec-WebSocket-Protocol` header. The token is base64url-encoded and sent as a subprotocol, alongside a sentinel subprotocol:\n\n``` js\nimport { fetchAuthSession } from \"aws-amplify/auth\";\n\nconst session = await fetchAuthSession();\nconst token = session.tokens?.accessToken?.toString();\n\nconst base64url = (s: string) =>\n  btoa(s).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=/g, \"\");\n\nconst url =\n  `wss://bedrock-agentcore.${region}.amazonaws.com` +\n  `/runtimes/${encodeURIComponent(runtimeArn)}/ws` +\n  `?qualifier=DEFAULT&X-Amzn-Bedrock-AgentCore-Runtime-Session-Id=${sessionId}`;\n\nconst protocols = [\n  `base64UrlBearerAuthorization.${base64url(token)}`,\n  \"base64UrlBearerAuthorization\",\n];\n\nconst ws = new WebSocket(url, protocols);\n```\n\nThat `Amazon Cognito` access token is exactly what the runtime's JWT authorizer validates. The user's `client_id` claim has to match the `allowedClients` we configured, which it does, because it's the same user pool client `Amplify` gave us.\n\nFrom here on, the rest of the frontend doesn't change at all. The same code that captured microphone audio, streamed PCM frames, and played back the agent's voice against the local server now works against AgentCore. Only the URL and the auth changed.\n\nEverything lives in one `Amplify Gen 2` backend and deploys with a single `npx ampx sandbox`:\n\nThe browser signs in once with `Amazon Cognito`. That identity gets it into the app, into the search API, into the chat, and now into the voice agent too.\n\nA couple of things stood out while building this.\n\nThe first is how little the agent code changed between local and deployed. The same FastAPI + Strands `BidiAgent` server ran on my laptop and, unchanged, inside `Amazon Bedrock AgentCore`. The container contract (port 8080, `/ws`, `/ping`) is simple enough that \"make it a container\" was the only real step.\n\n**The second is the value of keeping it all in one backend. Because `Amplify Gen 2` is `CDK`, the runtime, its image, its `IAM`, and its wiring to `Amazon Cognito` are all just constructs next to my data and auth definitions. The voice agent isn't a separate system I have to operate, it's another resource in the same deploy, sharing the same users.**\n\nThe voice agent that used to live on my laptop now runs on AWS, authenticated with the users my app already had, deployed with the same command as everything else.\n\n**Your `Amplify Gen 2` deployed `Amazon DynamoDb` database was already an AI tool.\nNow the agent that talks to it is serverless and, thanks to `CDK`, it's wired to `Amplify Gen 2` deployments too.**\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\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/deploying-a-real-time-voice-agent-with-agentcore-runtime-and-amplify-gen-2", "canonical_source": "https://dev.to/aws-builders/deploying-a-real-time-voice-agent-with-agentcore-runtime-and-amplify-gen-2-45bl", "published_at": "2026-09-16 20:58:25+00:00", "updated_at": "2026-09-16 21:23:25.245654+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "ai-products", "developer-tools", "agent-protocols"], "entities": ["Amazon Bedrock AgentCore Runtime", "AWS Amplify Gen 2", "Amazon DynamoDB", "Amazon Nova Sonic", "Strands", "FastAPI", "AWS", "Amazon DynamoDB Vector Search"], "alternates": {"html": "https://wpnews.pro/news/deploying-a-real-time-voice-agent-with-agentcore-runtime-and-amplify-gen-2", "markdown": "https://wpnews.pro/news/deploying-a-real-time-voice-agent-with-agentcore-runtime-and-amplify-gen-2.md", "text": "https://wpnews.pro/news/deploying-a-real-time-voice-agent-with-agentcore-runtime-and-amplify-gen-2.txt", "jsonld": "https://wpnews.pro/news/deploying-a-real-time-voice-agent-with-agentcore-runtime-and-amplify-gen-2.jsonld"}}