01 — AMAZON BEDROCK AGENTCORE
AgentCore Runtime requires a TURN relay for WebRTC media. Metered is a managed third-party TURN option: relays in 31+ regions, TURN over TLS on port 443 and credentials from a REST API, with no relay servers or signaling channels for you to run.
Browser or mobile app
your users, behind firewalls and NAT
Metered TURN relay
managed · nearest of 31+ regions
YOUR AWS VPC · PRIVATE SUBNETS
AgentCore Runtime
your agent · no public IP
NAT gateway
symmetric NAT
✕ No direct path: the runtime can't take a public IP, and STUN fails behind symmetric NAT.
02 — WHY TURN
WebRTC on AgentCore Runtime runs in VPC network mode, and the runtime can't be assigned a public IP. AWS NAT gateways use symmetric NAT, so a STUN-only connection can't succeed either. That's why AWS requires a TURN relay for media between the client and the agent.
VPC NETWORK MODE
WebRTC on AgentCore Runtime requires VPC network mode, with egress through a NAT gateway.
AWS: WebRTC on AgentCore RuntimeNO DIRECT PATH
The runtime can't take a public IP, and symmetric NAT on AWS NAT gateways stops STUN-based direct connections.
AWS Machine Learning Blog, March 2026TURN REQUIRED
AWS requires a TURN relay for media traffic, and your VPC must allow outbound UDP to the TURN endpoints.
AWS: TURN requirements03 — SETUP
Four steps, starting from the WebRTC voice agent in AWS's AgentCore samples.
Code adapted from awslabs/agentcore-samples, © Amazon.com, Inc. or its affiliates, Apache License 2.0.
STEP 1
Create a TURN credential in the Metered dashboard or with the Create Credential API. Store the credential's API key in AWS Secrets Manager and give the agent's execution role secretsmanager:GetSecretValue on that secret.
The API key only returns this credential's ICE servers, so it is safe in front-end code. Keeping it in Secrets Manager rather than the runtime's environment variables, which the AgentCore control API returns in plain text, lets you rotate credentials without redeploying.
# metered-turn-credential.json holds {"apiKey": "YOUR_CREDENTIAL_API_KEY"}.
# Reading it from a file keeps the key out of your shell history.
aws secretsmanager create-secret \
--name metered/turn-credential \
--secret-string file://metered-turn-credential.json
STEP 2
Add a small module that reads the API key and calls Metered's Get Credential API. It returns STUN plus TURN over UDP, TCP and TLS, ready for RTCConfiguration.
Then make three edits to the sample's agent/bot.py: import the module in place of the sample's managed-TURN helper, drop the signaling-channel setup from lifespan, and fetch Metered's ICE servers in the ice_config and offer handlers. The ice_config handler becomes async, so invocations() has to await it.
Fetch per request, not at startup. Add METERED_DOMAIN and METERED_SECRET_ID to the runtime's environment variables in the sample's deploy script (neither is a secret). The sample's channel-name setting and its managed-TURN IAM policy are no longer needed.
aiortc uses the first TURN URL in the list. With Metered's list that's TURN over UDP on port 80, which is what VPC mode is set up for.
# Metered TURN for an AgentCore WebRTC agent (example)
import asyncio, json, os, urllib.parse, urllib.request
import boto3
from aiortc import RTCConfiguration, RTCIceServer
METERED_DOMAIN = os.environ["METERED_DOMAIN"] # e.g. yourapp.metered.live
SECRET_ID = os.environ["METERED_SECRET_ID"] # e.g. metered/turn-credential
_secrets = boto3.client("secretsmanager", region_name=os.environ.get("AWS_REGION"))
def _fetch_ice_servers() -> list[dict]:
secret = json.loads(_secrets.get_secret_value(SecretId=SECRET_ID)["SecretString"])
query = urllib.parse.urlencode({"apiKey": secret["apiKey"]})
url = f"https://{METERED_DOMAIN}/api/v1/turn/credentials?{query}"
with urllib.request.urlopen(url, timeout=5) as resp: # raises on HTTP errors
return json.load(resp) # [{"urls", "username", "credential"}, ...]
async def get_ice_servers() -> list[dict]:
# boto3 and urllib block, so keep them off the event loop
return await asyncio.to_thread(_fetch_ice_servers)
def rtc_configuration(servers: list[dict]) -> RTCConfiguration:
return RTCConfiguration(iceServers=[
RTCIceServer(urls=s["urls"], username=s.get("username"), credential=s.get("credential"))
for s in servers
])
import metered_turn # replaces the sample's managed-TURN helper module
@asynccontextmanager
async def lifespan(app: FastAPI):
# The sample's signaling-channel setup line goes: Metered needs no channel
yield
for pc in peer_connections.values():
await pc.close()
@app.post("/invocations")
async def invocations(request: dict, background_tasks: BackgroundTasks):
action = request.get("action")
if action == "ice_config":
return await _handle_ice_config() # now async, so await it
# ...offer, ice_candidate and disconnect stay as they are
async def _handle_ice_config():
# The browser gets the ICE server list; it never needs your Metered API key
return {"iceServers": await metered_turn.get_ice_servers()}
async def _handle_offer(data, background_tasks):
servers = await metered_turn.get_ice_servers()
pc = RTCPeerConnection(metered_turn.rtc_configuration(servers))
# ...the rest of the sample's offer handling stays the same
STEP 3
The sample's test page calls InvokeAgentRuntime straight from the browser with pasted AWS credentials. In production, put that call behind your own backend: it signs the request and reuses one session ID for every step of a call.
The browser asks for the agent's ICE config, then builds its peer connection from that list. You don't need a signaling channel resource: the offer, the ICE candidates and the hang-up all go through the invoke API.
// invokeAgent(action, data) is your call to your own backend,
// which signs InvokeAgentRuntime for this session.
const { iceServers } = await invokeAgent("ice_config");
const pc = new RTCPeerConnection({ iceServers });
// To test the relay path on its own:
// new RTCPeerConnection({ iceServers, iceTransportPolicy: "relay" })
// The offer, ICE candidates and disconnect use the same
// actions as the sample.
STEP 4
Run the agent in private subnets whose default route points at a NAT gateway, then allow these outbound ports in the agent's security group.
Security groups can't match hostnames, so the destination is 0.0.0.0/0 on these ports. No inbound rules are needed. An aiortc agent needs only UDP 80 and TCP 443; keep the other two if you change libraries or reorder the ICE list.
| PROTOCOL | PORT | USED FOR |
|---|---|---|
| UDP | 80 | TURN over UDP (the entry aiortc uses) and STUN |
| UDP | 443 | TURN over UDP on 443 |
| TCP | 80 | TURN over TCP |
| TCP | 443 | TURN over TCP and TLS, HTTPS to your metered.live API, and the AWS APIs your agent calls (Secrets Manager, Bedrock, ECR, CloudWatch) |
04 — PRODUCTION
Never create a credential at call setup: a new one takes up to two minutes to start working. Create a 48-hour credential once a day and switch your secret to it a few minutes later, once it is live. The rotation job keeps your account Secret Key in its own secret, which the agent's role can't read.
Rotation guideAdd a region to the ICE request to direct relays to one geography, such as Europe.
Region guideLog the status of the ICE request, never its URL or a traceback's local variables: the query string carries the credential's API key.
The Pipecat example linked from AWS's AgentCore examples reads TURN settings from environment variables. Fetch Metered's ICE servers per session instead, since the runtime's environment variables are readable in plain text.
AWS: AgentCore WebRTC examples05 — PLANS
Prototype your agent on the free trial, then move to the plan, or the contract, that fits your traffic.
STARTUPS AND GROWING TEAMS
06 — FAQ
Start with 500 MB of TURN every month, free. No signaling channel, no TURN servers to run.