Deploying a real-time voice agent with AgentCore Runtime and Amplify Gen 2 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. 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. That voice agent ran locally. A Python server on my laptop, a WebSocket, my microphone. Great for a demo, but it lives on my machine. So the question for this article is: how do I actually deploy it? I 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. It turns out this fits together really nicely with Amazon Bedrock AgentCore Runtime . Let me walk through it. Companion posts: 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, 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. It demonstrates the same idea through three interfaces: Quick 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. python from fastapi import FastAPI, WebSocket from strands.experimental.bidi import BidiAgent from strands.experimental.bidi.models import BidiNovaSonicModel from strands import tool @tool def search products query: str - str: """Search the product catalog using natural language.""" Nova Micro parses the query, Titan embeds it, DynamoDB SearchVectors finds the matches, we filter by price. ... sonic model = BidiNovaSonicModel model id="amazon.nova-2-sonic-v1:0", ... app = FastAPI @app.websocket "/ws" async def voice chat websocket: WebSocket : agent = BidiAgent model=sonic model, tools= search products , ... await websocket.accept await agent.run inputs= websocket.receive json , outputs= websocket.send json , Locally I ran this with uvicorn , the browser connected to ws://127.0.0.1:8080/ws , and everything worked. Now I want the exact same code running on AWS. 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. 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. Amazon Bedrock AgentCore handles the rest: session isolation, scaling, authentication, and the public WebSocket endpoint. So the plan is: And 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. Amazon Bedrock AgentCore Runtime runs ARM64 containers. The Dockerfile is minimal: FROM --platform=linux/arm64 python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY agent.py . ENV CONTAINER ENV=true EXPOSE 8080 CMD "python", "agent.py" The 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 : host = "0.0.0.0" if os.getenv "CONTAINER ENV" else "127.0.0.1" uvicorn.run app, host=host, port=8080 Here'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 . First, 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 : js import as ecrAssets from "aws-cdk-lib/aws-ecr-assets"; const voiceImage = new ecrAssets.DockerImageAsset voiceStack, "VoiceAgentImage", { directory: path.join dirname, "..", "voice-agent" , platform: ecrAssets.Platform.LINUX ARM64, } ; Then 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: js const voiceRuntimeRole = new iam.Role voiceStack, "VoiceAgentRuntimeRole", { assumedBy: new iam.ServicePrincipal "bedrock-agentcore.amazonaws.com", { conditions: { StringEquals: { "aws:SourceAccount": account } }, } , } ; voiceImage.repository.grantPull voiceRuntimeRole ; voiceRuntimeRole.addToPolicy new iam.PolicyStatement { actions: "ecr:GetAuthorizationToken" , resources: " " , } ; voiceRuntimeRole.addToPolicy new iam.PolicyStatement { actions: "bedrock:InvokeModel", "bedrock:InvokeModelWithBidirectionalStream", , resources: "arn:aws:bedrock: ::foundation-model/amazon.nova-2-sonic-v1:0", "arn:aws:bedrock: ::foundation-model/amazon.titan-embed-text-v2:0", "arn:aws:bedrock: ::foundation-model/amazon.nova-micro-v1:0", arn:aws:bedrock: :${account}:inference-profile/eu.amazon.nova-micro-v1:0 , , } ; voiceRuntimeRole.addToPolicy new iam.PolicyStatement { actions: "dynamodb:SearchVectors", "dynamodb:GetItem" , resources: productsTable.tableArn, ${productsTable.tableArn}/index/ , } ; And 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. js import { CfnRuntime } from "aws-cdk-lib/aws-bedrockagentcore"; const voiceRuntime = new CfnRuntime voiceStack, "VoiceAgentRuntime", { agentRuntimeName: "voiceShoppingAgent", agentRuntimeArtifact: { containerConfiguration: { containerUri: voiceImage.imageUri }, }, networkConfiguration: { networkMode: "PUBLIC" }, protocolConfiguration: "HTTP", roleArn: voiceRuntimeRole.roleArn, environmentVariables: { CONTAINER ENV: "true", TABLE NAME: productsTable.tableName, BEDROCK REGION: "eu-north-1", // ...model ids }, authorizerConfiguration: { customJwtAuthorizer: { discoveryUrl, allowedClients: userPoolClient.userPoolClientId , }, }, } ; That 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 . One 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. This 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. 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: js const userPool = backend.auth.resources.userPool; const userPoolClient = backend.auth.resources.userPoolClient; const discoveryUrl = https://cognito-idp.${region}.amazonaws.com/ + ${userPool.userPoolId}/.well-known/openid-configuration ; // ...passed into the runtime's authorizerConfiguration.customJwtAuthorizer Now 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. The 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: new iam.Policy voiceStack, "VoiceAgentInvokePolicy", { roles: backend.auth.resources.authenticatedUserIamRole , statements: new iam.PolicyStatement { actions: "bedrock-agentcore:InvokeAgentRuntime", "bedrock-agentcore:InvokeAgentRuntimeWithWebSocketStream", , resources: voiceRuntime.attrAgentRuntimeArn, ${voiceRuntime.attrAgentRuntimeArn}/ , , } , , } ; The 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. The last piece is the frontend. Locally the browser connected to ws://127.0.0.1:8080/ws . Deployed, it connects to the AgentCore endpoint: wss://bedrock-agentcore.