Akshay Parkhi's Weblog

What I Learned Building a Streaming Agent on AWS Bedrock AgentCore Runtime

16th February 2026

What I Learned Building a Streaming Agent on AWS Bedrock AgentCore Runtime

I spent a week building a conversational agent on AWS Bedrock AgentCore Runtime. It supports:

  • hybrid memory (fast in-session + persistent cross-session)
  • real-time token streaming
  • multi-user isolation
  • a 12-test automated suite

Here is everything I wish someone told me before I started.

The full source code is available on GitHub: github.com/avparkhi/AWS-BedrockAgentCore-Testing


1. The MicroVM Mental Model

AgentCore doesn’t run your agent in a container you manage. It runs inside microVMs. These are tiny, isolated virtual machines that start per session. The first request to a new session triggers a cold start (~1.5–10s depending on what you initialize). Subsequent requests to the same session hit a warm microVM where all your Python globals are still in memory (~180ms overhead).

This means you can cache expensive things as module-level globals, and they persist across warm invocations. Examples: model clients, config, database connections. But the moment a user creates a new session, a new microVM starts and everything resets.

Think of it like AWS Lambda, but with a session-sticky routing layer on top.

2. Memory is a Two-Tier Problem

The session-sticky microVM model creates an interesting memory challenge:

  • Within a session (warm starts): You want fast, in-process memory. A simple Python dictionary works. It has zero latency and makes no API calls. This is your RAM layer.
  • Across sessions (cold starts, redeploys, crashes): You need persistent storage. AgentCore provides a Memory API for this. It is durable and survives everything, but it adds ~300ms per read.

The hybrid approach: always save to durable memory (background, on every message). On warm starts, use RAM. On cold starts, load from durable memory. The user never notices the difference.

Observation: The right split was to make the model global and the agent per-invocation. You reuse the global model across invocations. The per-invocation agent is lightweight, so lifecycle hooks fire correctly. Model initialization is expensive. Agent creation is cheap.

3. Streaming Changes the User Experience Dramatically

Without streaming, a typical warm chat response takes ~5.4 seconds. The user sees nothing until the entire response is ready. With streaming, the first token appears in ~1–1.5 seconds. The total time is the same, but the perceived latency drops by 75%.

MetricNon-StreamingStreaming
Time to first visible output~5.4s~1–1.5s
Total response time~5.4s~5.4s
User perception“Is it frozen?”“It’s thinking and typing”

If your agent takes more than 2 seconds to respond, streaming isn’t a nice-to-have. It’s table stakes for user experience.

4. The SDK Has Opinions About Streaming (And They’re Inconvenient)

The AgentCore SDK supports streaming on the server side well. If your entrypoint returns a Python generator, the SDK automatically detects it and wraps the response as Server-Sent Events (SSE). You need no configuration.

The problem is on the client side. The SDK’s built-in invoke method handles streaming by printing chunks directly to the console. It then returns an empty dictionary. There is no programmatic access to the streamed content.

If you want to parse streaming responses in your own client, you need to bypass the SDK and call the API directly via boto3. This applies if you want to:

  • show metadata
  • measure time-to-first-token
  • show formatted output

It’s not hard, but it’s undocumented and unexpected.

5. The Five Gotchas

These are the things that cost me the most time and are not documented anywhere I could find.

Gotcha #1: The Payload Isn’t a Real Dict

The payload your entrypoint receives looks like a Python dictionary, but it’s actually a JSONSerializableDict. It behaves differently in subtle ways:

  • The two-argument form of .get(key, default) throws a TypeError. Only the single-argument form works.
  • Bracket access (payload["key"]) throws a TypeError. There is no subscript support.
  • Assignment (payload["key"] = value) also fails.

The workaround is to use single-argument .get() with an or fallback for defaults. It’s ugly, but it works reliably.

The same limitation applies to agent state objects in the Strands SDK. Don’t try to use them as dicts. Store your state elsewhere.

Gotcha #2: Return Values Get Double-Encoded

The SDK JSON-encodes whatever your entrypoint returns. If you return a JSON string, the client receives a JSON-encoded JSON string. That means escaped quotes, escaped newlines, the works. You then need to decode the response iteratively (sometimes 3–4 rounds) to get the original data.

In the end, I used a custom tag format for metadata instead of JSON, just to avoid the encoding nightmare.

Gotcha #3: Streaming Generators Have the Same Encoding Trap

This was the most frustrating bug. When your streaming generator yields values, the SDK JSON-encodes each one for the SSE wire format. If you pre-encode your dicts to JSON strings before yielding, the SDK encodes them again. The client receives a quoted string instead of a parseable dict.

The symptom: metadata objects appear as raw text inline with the chat response, and all metadata fields show as “unknown.”

The fix: yield raw Python objects (dicts, strings) and let the SDK handle serialization. Never call json.dumps() on values you’re about to yield from a streaming generator.

The rule I wish I’d known from the start: In streaming generators, the SDK is the serializer. You are the data source. Don’t do the SDK’s job. It will do it again on top of yours.

Gotcha #4: Non-Streaming Responses Silently Truncate at 1024 Bytes

There is no error. There is no warning. If your non-streaming response exceeds 1024 bytes, it is truncated mid-JSON. You spend an hour debugging your JSON parser before you realize the data was simply incomplete.

Keep non-streaming responses compact. For my memory query mode, I had to truncate each conversation turn to ~80 characters to stay under the limit.

Gotcha #5: A Generator Function Can’t Conditionally Stream

In Python, a function that contains yield anywhere in its body always returns a generator. You can’t conditionally choose between returning a string and yielding chunks from the same function.

The solution is a dispatch pattern. Your entrypoint is a regular function that returns either a generator object (for streaming) or a string (for non-streaming). The SDK inspects the return value’s type, not the function itself. You write two separate handler functions and one thin dispatcher.

6. Testing Observations

Test Through the Non-Streaming Path

All 12 of my automated tests use non-streaming mode. Streaming responses are harder to assert against (you’d need to reconstruct the full text from chunks). Non-streaming gives you a clean, complete response string to validate.

Streaming is a transport concern. If your logic works non-streaming, it works streaming. The same code runs either way, just yielded differently.

Memory Persistence Is the Key Test

The most important test has two steps. Send a math problem on session A. Then ask about the result on a completely different session B. Session B has empty RAM (new microVM). If the agent remembers the answer, durable memory works. If the response attributes the memory source as “durable,” the hybrid architecture works correctly.

Isolation Tests Are Easy to Get Wrong

Testing that User 2 doesn’t see User 1’s history is tricky because LLMs can coincidentally mention the same numbers. Testing that the response doesn’t contain both “50” and “48” (from User 1’s specific calculation chain) is more reliable than testing for either alone. Even so, these tests can be flaky.

Observed Latencies

OperationCold StartWarm
Ping (no LLM, pure microVM overhead)~1.7s~180ms
Chat (LLM + memory hooks)~8–17s~5.4s
Chat streaming (time to first token)—~1–1.5s
Memory query (durable read, no LLM)—~350ms

The wide range on cold-start chat (8–17s) comes from two things: microVM provisioning and the first-time initialization of the model and memory clients. Subsequent cold starts are faster (~8s) because ECR image layers are cached.

7. Observability Setup

AgentCore supports three observability pillars, all routed through the runtime role’s IAM permissions:

PillarWhere It GoesWhat You See
LogsCloudWatch LogsTwo streams: runtime-logs (your print output) and otel-rt-logs (OpenTelemetry)
TracesX-Ray via OTelEnd-to-end request traces with timing breakdown
MetricsCloudWatch MetricsInvocation count, latency, errors under the bedrock-agentcore namespace

The runtime role needs permissions for all three:

  • create and write to log groups and log streams
  • submit X-Ray segments and telemetry
  • publish CloudWatch metrics, scoped to the bedrock-agentcore namespace
Easy to miss: The runtime config must set the server protocol to HTTP for runtime-logs to be captured. Without this, your agent’s print statements go nowhere.

The GenAI Observability Dashboard in the CloudWatch console provides a unified view across all your AgentCore agents. It is worth bookmarking.

8. Deployment Notes

  • CodeBuild is the default and easiest path. It builds ARM64 containers in the cloud. You do not need local Docker. Build time is ~40 seconds.
  • Use your venv Python. On macOS, the system Python is typically 3.9.x. The SDK needs 3.11+. I wasted time on cryptic import errors before I realized I had the wrong interpreter.
  • Memory resources are idempotent-ish. Creating a memory that already exists throws a ValidationException. You need to catch it and find the existing one via list. It is not a big deal, but it is not obvious from the API.
  • The agent ARN lives in the YAML config (.bedrock_agentcore.yaml), not in your deploy info JSON. You need it for direct boto3 calls. The SDK handles this internally when you use its invoke method.
  • Redeployment is simple. Update your code and run deploy. The agent updates in place. Session IDs reset, but durable memory persists. Warm microVMs from the old version drain naturally.

9. What I’d Do Differently Next Time

  1. Build non-streaming first, add streaming last. Streaming is purely a transport layer concern. Get your agent logic, memory, and tools working with simple request/response. Then add streaming on top. It is miserable to debug streaming issues while your core logic is also broken.
  2. Assume everything the SDK touches will be JSON-encoded. This includes return values, streaming yields, and headers. If the SDK handles it, expect encoding. Design your data formats around this from day one.
  3. Keep non-streaming responses tiny. Because of the 1024-byte truncation limit, you should design response formats to be compact from the start. Do not try to shrink them later.
  4. Write the ping test first. A no-LLM ping mode that returns cache status and invocation count is invaluable for debugging microVM behavior. It isolates platform issues from agent logic issues. Every new agent I build will start with this.
  5. Use tags, not JSON, for non-streaming metadata. A simple delimited format like <<META:key=val,key=val>> survives the SDK’s encoding gauntlet intact. JSON metadata in non-streaming responses is a losing battle.
End result: A production-ready agent with 12/12 tests passing. Its hybrid memory works across sessions and restarts. Its streaming drops perceived latency from 5.4s to 1.5s. And I have a clear understanding of every sharp edge in the platform.

More recent articles

This is What I Learned Building a Streaming Agent on AWS Bedrock AgentCore Runtime by Akshay Parkhi, posted on 16th February 2026.

Next: GR00T N1.6 Architecture and Parameter Distribution

Previous: IAM Permissions for BedrockAgentCoreApp Observability