{"slug": "skip-the-middleman-connecting-your-ui-directly-to-an-ai-agent-via-websocket", "title": "Skip the Middleman: Connecting Your UI Directly to an AI Agent via WebSocket", "summary": "A developer built WearCast, an AI agent that helps users choose outfits based on weather, using AgentCore's built-in WebSocket support to stream responses directly from the AI agent to the browser without intermediary Lambda functions. The approach uses a presigned WebSocket URL generated via SigV4 signing, eliminating the need for managing connection IDs or proxy services.", "body_md": "I've been building AI agents for a while now, and streaming responses to a UI has always been the painful part. In previous projects I tried API Gateway streaming, Lambda response streaming, and even AppSync Events via an agent tool call to notify the UI. I also looked at adding my own WebSocket API through API Gateway, which requires managing `$connect`\n\n, `$disconnect`\n\n, and `$default`\n\nroutes, storing connection IDs in DynamoDB, and posting messages back through `@connections`\n\n. All of these approaches felt like too much ceremony for what should be simple.\n\nThat's when I found that AgentCore has built-in WebSocket support. The browser can just connect directly to the agent. No middleman.\n\nI built WearCast to demonstrate this functionality. This is an AI agent that helps you pick out what to where based on the weather. I've found this very helpfu when packing for a trip. The code for this application is public and you can find the full implementation on [this GitHub repo](https://github.com/andmoredev/wearcast/).\n\nWe usually build app applications as we do REST APIs\n\n```\nUser → REST API → Lambda → AI Service → Lambda → REST API → User\n```\n\nEvery message makes a round trip through multiple intermediaries. The response waits until the entire generation is complete, and then it all comes back at once. For a chat interface, this feels sluggish. Users stare at a spinner while the model generates hundreds of tokens they could already be reading.\n\nEven if you add Server-Sent Events or long-polling, you're still stitching together a real-time experience on top of infrastructure that seemed like overkill. I wanted something better.\n\nHere's what I ended up with:\n\n```\n┌─────────────┐          ┌──────────────────┐\n│   React UI  │─── JWT ─▶│  API Gateway +   │──▶ Lambda (presigned URL)\n│  (Cognito)  │          │  Cognito Auth    │\n└──────┬──────┘          └──────────────────┘\n       │\n       │  WebSocket (SigV4 presigned URL)\n       │  ← No middleman! Direct connection →\n       ▼\n┌──────────────────────────────────┐     ┌────────────────────┐\n│  AgentCore Runtime               │────▶│  AgentCore Memory  │\n│  (Strands Agent + Bedrock LLM)   │     │  (Persistence)     │\n└──────────────────────────────────┘     └────────────────────┘\n```\n\nThe browser connects directly to the AI agent over a WebSocket without the need for any Lambda functions to proxy the tokens. The agent streams tokens directly to the user's browser as they're generated.\n\nThe only additional infrastructure is during the initial handshake, where we exchange a JWT for a presigned WebSocket URL. After that, it's a direct, bidirectional pipe.\n\nLet's go over the three steps to get this working.\n\nThe user signs in through Amazon Cognito and receives a JWT token. Nothing unusual here.\n\n``` js\n// Frontend authenticates and gets an access token\nconst accessToken = await authService.getAccessToken();\n```\n\nHere's where it gets interesting. The frontend makes a single REST call to our backend:\n\n``` js\nconst presignedData = await apiService.getPresignedWebSocketUrl(sessionId, accessToken);\n```\n\nBehind the scenes, a Lambda function:\n\n``` js\n// Lambda: Generate presigned WebSocket URL\nconst wsHost = `bedrock-agentcore.${region}.amazonaws.com`;\nconst wsPath = `/runtimes/${runtimeArn}/ws`;\n\nconst signer = new SignatureV4({\n  service: 'bedrock-agentcore',\n  region,\n  credentials,\n  sha256: Sha256\n});\n\nconst signedRequest = await signer.presign(request, { expiresIn: 300 });\nconst presignedWsUrl = formatSignedUrl(signedRequest).replace('https://', 'wss://');\n```\n\nThe presigned URL is valid for 5 minutes, long enough to establish the connection, short enough to limit exposure. After the URL expires, it can reconnect by getting a new presigned URL, similar to how we handle expiring authentication tokens.\n\nThe browser opens a WebSocket connection using the presigned URL. No custom headers needed, all authentication is embedded in the URL's query parameters via SigV4.\n\n```\nthis.ws = new WebSocket(presignedData.wsUrl);\n```\n\nOnce connected, the browser sends messages directly to the agent and receives streaming responses in real-time:\n\n```\n// Send a message\nws.send(JSON.stringify({\n  request: \"What should I wear in Chicago today?\",\n  session_id: sessionId,\n  user_id: userId\n}));\n\n// Receive streaming tokens\nws.onmessage = (event) => {\n  const data = JSON.parse(event.data);\n  if (data.event?.data) {\n    // Append token to the UI immediately\n    appendToStream(data.event.data);\n  }\n};\n```\n\nThat's it. The browser is now talking directly to the AI agent. Every token streams in as it's generated.\n\nLet me go over what makes this better than the traditional setup.\n\nEvery token arrives at the browser the moment the model generates it. There's no buffering layer, no Lambda invocation overhead per chunk, no API Gateway.\n\nAs I mentioned earlier, I've done the traditional API Gateway WebSocket approach and the AppSync Events approach. Both work, but they have a lot of moving parts where they are not needed.\n\nWith this approach all you need is:\n\nWith this approach you remove the need for any extra components that might generate cost. You only pay for the AgentCore Runtime and one Lambda invocation per session establishment.\n\nThe WebSocket stays open for multi-turn conversations. The agent maintains state across messages within the same connection, without the need to reload context on every request.\n\nA direct WebSocket connection is great, but what happens when the user steps away or opens the conversation on a different device? Without memory, the agent starts fresh every time.\n\nWearCast uses AgentCore Memory, a module provided by AgentCore to keep a history of the conversation to give the agent better context awareness\n\n``` python\nfrom bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig\nfrom bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager\n\ndef create_session_manager(runtime_session_id, user_id):\n    config = AgentCoreMemoryConfig(\n        memory_id=AGENTCORE_MEMORY_ID,\n        session_id=runtime_session_id,\n        actor_id=user_id\n    )\n    return AgentCoreMemorySessionManager(\n        agentcore_memory_config=config,\n        region_name=AWS_REGION\n    )\n```\n\nIn this case we are using Strands session manager functionality which takes care of automatically loading previous messages from memory.\n\nNew messages are added to the context as they are happening. With this the user can close their laptop, come back hours later, and pick up right where they left off.\n\nThe memory is scoped by session ID and actor ID (user), so each user's conversations are isolated and private.\n\nThe memory resource is declared right alongside the runtime in the SAM template:\n\n```\nAgentCoreShortTermMemory:\n  Type: AWS::BedrockAgentCore::Memory\n  Properties:\n    Name: WearCast\n    Description: Short-term memory for agent conversation persistence\n    MemoryExecutionRoleArn: !GetAtt AgentCoreRole.Arn\n    EventExpiryDuration: 30  # days\n```\n\nThe memory ID is passed to the agent as an environment variable for the session manager to use.\n\nAn agent without tools is just a chatbot. Tools turn it into something that can actually do things.\n\nWearCast includes a `get_weather`\n\ntool that fetches real forecast data from Open-Meteo (no API key required):\n\n``` php\n@tool\ndef get_weather(city: str, date: str = \"today\") -> dict:\n    \"\"\"Get weather conditions for a city, current or up to 16 days ahead.\n\n    Args:\n        city: City name (e.g. \"Indianapolis\", \"Chicago\")\n        date: \"today\" for current, or YYYY-MM-DD for forecast\n    \"\"\"\n    # Geocode the city\n    geo_url = f\"https://geocoding-api.open-meteo.com/v1/search?name={city}&count=1\"\n    # ... fetch coordinates ...\n\n    # Get weather data\n    forecast_url = f\"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&...\"\n    # ... fetch and return weather data ...\n```\n\nWith Strands, tools are just decorated Python functions. The `@tool`\n\ndecorator handles:\n\nOn the frontend, tool usage is communicated through the same WebSocket stream:\n\n```\nif (event.current_tool_use?.name) {\n  setCurrentTool(event.current_tool_use.name);\n  // Show \"Using get_weather...\" indicator\n}\n```\n\nThe user sees the agent \"thinking,\" then using a tool, then formulating its response, all streaming in real-time. It feels like watching someone work, not waiting for an answer.\n\nYou might be asking yourself, isn't it dangerous to let browsers connect directly to your AI agent? Not when you layer the security correctly:\n\n```\n// User identity travels with the signed URL\nqueryParams['X-Amzn-Bedrock-AgentCore-Runtime-Custom-User-Id'] = userId;\n\n// The agent receives it as a header\nheaders = context.request_headers\nuser_id = headers.get(\"x-amzn-bedrock-agentcore-runtime-custom-user-id\")\n```\n\nThe browser never sees AWS credentials. The Lambda's role does the signing. The user's identity is cryptographically bound to the connection.\n\nLet me quickly go over the full flow from start to finish:\n\n`POST /websocket/connect`\n\nwith JWT`new WebSocket(presignedUrl)`\n\nThis works well when:\n\nIt may not be the right fit when:\n\nThe key components:\n\n`backend/functions/websocket-connect.js`\n\n: The presigned URL generator (the only \"middleman\")`backend/agents/agent/agent.py`\n\n: The agent with WebSocket streaming, memory, and tools`frontend/src/services/websocket.ts`\n\n: The browser-side WebSocket client`backend/template.yaml`\n\n: The complete infrastructure definitionI'm really happy with how this turned out. The WebSocket approach removed so much complexity from the architecture and the user experience is noticeably better with real-time streaming. I built this on the side but I've already used the same pattern for several projects at work. The fact that AgentCore handles the WebSocket connection management for us means we don't have to deal with any of the typical WebSocket infrastructure headaches.\n\nLet me know what you think about this approach!\n\nAndres Moreno", "url": "https://wpnews.pro/news/skip-the-middleman-connecting-your-ui-directly-to-an-ai-agent-via-websocket", "canonical_source": "https://dev.to/aws-builders/skip-the-middleman-connecting-your-ui-directly-to-an-ai-agent-via-websocket-29f7", "published_at": "2026-07-15 15:37:00+00:00", "updated_at": "2026-07-15 16:11:06.460912+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["AgentCore", "Amazon Bedrock", "Amazon Cognito", "API Gateway", "Lambda", "WearCast", "AWS", "SigV4"], "alternates": {"html": "https://wpnews.pro/news/skip-the-middleman-connecting-your-ui-directly-to-an-ai-agent-via-websocket", "markdown": "https://wpnews.pro/news/skip-the-middleman-connecting-your-ui-directly-to-an-ai-agent-via-websocket.md", "text": "https://wpnews.pro/news/skip-the-middleman-connecting-your-ui-directly-to-an-ai-agent-via-websocket.txt", "jsonld": "https://wpnews.pro/news/skip-the-middleman-connecting-your-ui-directly-to-an-ai-agent-via-websocket.jsonld"}}