Streaming, Long-Running & Async Work
SSE, the 8-hour ceiling, and not starving /ping
- Stream incremental output by writing an async-generator `@app.entrypoint`, where each `yield` becomes one SSE `data:` event over `text/event-stream`
- Implement the long-running pattern: respond immediately, continue work in the background, and let the caller poll — using the same unified Runtime API for sync and async
- Keep a session alive past the idle timeout with `@app.ping` (`HEALTHY` vs `HEALTHY_BUSY`) and `@app.async_task` / `app.add_async_task` / `app.complete_async_task`
- Diagnose and avoid the blocking-entrypoint gotcha that starves the `/ping` thread and gets a session killed mid-job
- Explain why `/ping` must return `time_of_last_update` (Unix seconds, REQUIRED) and what breaks when it is omitted
- Reason about the duration ceilings (async 8 h, streaming 60 min, sync 15 min) and verify them against the live quotas page before relying on them
Not every agent call is a quick request/response. This lesson covers the two AgentCore Runtime patterns for work that takes time: streaming (an async-generator `@app.entrypoint` auto-serves `text/event-stream`, one SSE event per `yield`) and long-running/async background jobs (respond fast, keep working, let the user poll — backed by `@app.async_task` and a `/ping` `HealthyBusy` status). It hammers the two gotchas that kill sessions in production: blocking the entrypoint starves the `/ping` thread, and omitting `time_of_last_update` makes the idle timeout fire even while you report busy.
- 1Beyond quick request/response: three shapes of work
- 2Streaming: an async generator auto-serves SSE
- 3The long-running pattern: respond fast, keep working, let the user poll
- 4The /ping contract: status AND time_of_last_update
- 5Gotcha: don't block the entrypoint — you'll starve /ping
- 6Duration ceilings and how to read them
Beyond quick request/response: three shapes of work
The minimal BedrockAgentCoreApp agent you've already built handles a synchronous request: a caller invokes /invocations, your @app.entrypoint runs, you return a dict, the caller gets one response. That's perfect for fast tool calls and short completions. But real agents do work that doesn't fit a single fast turn:
- A long completion you want to stream token-by-token so the user sees progress instead of staring at a spinner.
- A multi-step research or data job that takes minutes to hours — far longer than any caller wants to hold a connection open for.
AgentCore Runtime handles both with the same unified API — you don't deploy a different service for async work. What changes is the shape of your entrypoint and how you keep the session alive. Three shapes:
| Shape | Entrypoint | Wire behavior | Duration ceiling [VOLATILE] |
|---|---|---|---|
| Synchronous | regular def returning a value | one request, one response | sync request timeout 15 min |
| Streaming | async def generator (yield) | text/event-stream (SSE), one event per yield | streaming max 60 min |
| Long-running / async | entrypoint that offloads work + tracks an async task | respond fast, caller polls; /ping reports HealthyBusy | async job max 8 hours |
The Runtime decides how to serve your function by inspecting what the entrypoint is — a value-returning function is served as a normal response; an async generator is auto-served as SSE. You don't flip a config flag; you write the appropriate function and the SDK wires the contract.
Watch out
Durations are volatile — verify before you design around them
The 15-minute / 60-minute / 8-hour numbers come from the Runtime quotas page and can change; the dossier flags them [VOLATILE]. Treat them as 'as of writing' ceilings, not timeless guarantees, and confirm them on the live quotas and long-running docs before you architect a job that runs near a limit. The async 8-hour max in particular is documented as not adjustable.
Streaming: an async generator auto-serves SSE
To stream, make your entrypoint an async generator — a coroutine that yields. AgentCore Runtime detects this and automatically serves the response as text/event-stream (Server-Sent Events). The rule is simple and exact: each yield becomes one SSE data: event. You don't import an SSE library, set a Content-Type header, or format data: lines yourself — the SDK does it.
Most agent frameworks already expose an async streaming API; you just relay their events:
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from strands import Agent
app = BedrockAgentCoreApp()
@app.entrypoint
async def handler(request: dict):
agent = Agent()
# Strands' stream_async yields incremental events; relay each one.
async for event in agent.stream_async(request.get("prompt", "")):
yield event # one yield -> one SSE data: event
if __name__ == "__main__":
app.run()Because the response is SSE, the caller reads a stream of events rather than waiting for a single body. You can test the exact cloud contract locally — app.run() binds http://localhost:8080, and the same /invocations endpoint will now emit a stream:
curl -N -X POST http://localhost:8080/invocations \
-H 'Content-Type: application/json' \
-d '{"prompt":"Write a short poem about Graviton."}'-N disables curl's buffering so you see events arrive incrementally. Each chunk on the wire is one data: event corresponding to one yield.
Streaming is the right tool when the total work still finishes inside the streaming window (max 60 min as of writing) and the value is showing progress. It is not the tool for a job that must outlive the connection — that's the long-running pattern below.
Tip
Streaming chunk size has its own limit
The Runtime quotas list a per-event streaming chunk ceiling (10 MB as of writing, [VOLATILE]). If a framework can emit a huge single event (e.g. a giant tool result), chunk it on your side rather than yielding one enormous payload.
Note
Sync, streaming, async — one entrypoint decorator
All three shapes use @app.entrypoint. The only difference is whether the function returns a value (sync), is an async generator (streaming), or kicks off background work and tracks it (async). There is no separate 'streaming runtime' to deploy.
The long-running pattern: respond fast, keep working, let the user poll
When a job takes minutes to hours, you cannot make the caller hold a connection open for it. The pattern AgentCore documents is:
- Start the long task.
- Respond immediately to the caller (e.g. an acknowledgement or a job handle).
- Keep working in the background inside the same session.
- Let the user poll for status/results on subsequent invocations of the same session.
The glue that makes step 3 possible is the /ping health endpoint and async task tracking. While the agent has background work in flight, /ping must report a busy status so Runtime knows the session is doing useful work and shouldn't be torn down at the idle timeout. Two SDK surfaces drive this:
@app.ping— a handler that returnsPingStatus.HEALTHY(ready/idle) orPingStatus.HEALTHY_BUSY(busy with async work; keeps the session alive past the idle timeout).@app.async_task/app.add_async_task("name")+app.complete_async_task(task_id)— async task tracking. While at least one tracked task is open,/pingautomatically reportsHealthyBusy.
Using the decorator, the SDK flips the status for you for the duration of the task:
import asyncio
from bedrock_agentcore.runtime import BedrockAgentCoreApp
app = BedrockAgentCoreApp()
@app.async_task
async def long_research(topic: str):
# multi-step work that can run for minutes/hours.
# While this task is open, /ping auto-reports HealthyBusy.
await do_the_real_work(topic)
@app.entrypoint
async def handler(request: dict):
action = request.get("action")
if action == "start":
asyncio.create_task(long_research(request["topic"])) # don't await it
return {"status": "started"} # respond immediately
if action == "status":
return {"status": check_progress()} # caller polls hereIf you'd rather manage the lifecycle by hand instead of via the decorator, use the explicit pair:
task_id = app.add_async_task("long_research") # /ping now reports HealthyBusy
try:
await do_the_real_work(topic)
finally:
app.complete_async_task(task_id) # /ping returns to Healthy when none remainNote that callers re-invoke the same session to poll. That's why reusing the runtimeSessionId across the start call and every poll call matters — it routes you back to the session that's holding your in-progress work. (A minimum session-id length of 33 characters is commonly cited; treat it as a guideline and confirm the exact constraint in the live API reference.)
Key insight
Why HealthyBusy exists at all
Runtime reaps idle sessions to free serverless capacity (idle timeout is 15 min as of writing, set by idleRuntimeSessionTimeout in LifecycleConfiguration). A long job has no inbound requests for long stretches, so it looks idle. HealthyBusy is how your container tells Runtime 'I'm not idle, I'm grinding' — which is exactly why a background task must keep /ping reporting busy until it's done.
The /ping contract: status AND time_of_last_update
Reporting HealthyBusy is necessary but not sufficient. The /ping response must return HTTP 200 with a body that includes time_of_last_update — and this field is Unix seconds and REQUIRED:
{ "status": "Healthy", "time_of_last_update": 1715000000 }Here is the gotcha that bites people in production: if you omit time_of_last_update, the idle timeout fires even while you are reporting HealthyBusy. The status alone does not keep the session alive — Runtime uses the timestamp to know the busy state is current. A stale or missing timestamp is treated as no heartbeat, and the session is reaped mid-job.
So the discipline for a long-running agent is: advance time_of_last_update as your background work makes progress. A custom @app.ping handler returns both the status and a fresh timestamp. When you hand-roll @app.ping, you also own deciding busy-ness — track it yourself (e.g. a flag you set/clear around the work):
import time
from bedrock_agentcore.runtime import BedrockAgentCoreApp, PingStatus
app = BedrockAgentCoreApp()
_busy = False
_last_progress = time.time()
def mark_progress():
global _last_progress
_last_progress = time.time() # call this as the background job advances
@app.ping
def ping():
return {
"status": PingStatus.HEALTHY_BUSY if _busy else PingStatus.HEALTHY,
"time_of_last_update": int(_last_progress), # Unix seconds, REQUIRED
}The simpler and safer path is to let the SDK manage /ping for you: when you use @app.async_task / add_async_task, the busy status is reported automatically while ≥1 task is open. If you hand-roll @app.ping, you own the whole contract — both the busy/idle status and always emitting a fresh time_of_last_update.
Watch out
Missing time_of_last_update is a silent session-killer
There's no loud error — your agent just gets torn down somewhere mid-job and the next poll returns nothing. If long jobs mysteriously die around the idle-timeout mark while your code 'is' reporting HealthyBusy, the first thing to check is whether /ping is returning a present, fresh time_of_last_update in Unix seconds.
Gotcha: don't block the entrypoint — you'll starve /ping
This is the single most important rule for long-running agents, and it's subtle because it has nothing to do with your business logic being correct.
The BedrockAgentCoreApp is a Starlette/Uvicorn ASGI app. The /invocations handler (your @app.entrypoint) and the /ping health endpoint share the same event loop / worker pool. A blocking call inside @app.entrypoint starves the /ping thread — Runtime stops getting healthy pings, decides the session is unresponsive, and kills it at 15 minutes during long work.
The trap is ordinary synchronous code that looks fine:
# BAD: blocks the event loop -> /ping is starved -> session reaped
@app.entrypoint
async def handler(request: dict):
result = expensive_sync_call(request["data"]) # CPU-bound or blocking I/O
return {"result": result}Fix it by offloading the work so the event loop stays free to answer /ping:
import asyncio
@app.entrypoint
async def handler(request: dict):
# Push blocking work onto a thread; the loop keeps serving /ping.
result = await asyncio.to_thread(expensive_sync_call, request["data"])
return {"result": result}For genuinely long jobs, combine both ideas: offload AND track it as an async task so /ping reports HealthyBusy with a fresh timestamp the whole time:
@app.entrypoint
async def handler(request: dict):
if request.get("action") == "start":
# fire-and-forget background coroutine; respond immediately
asyncio.create_task(run_tracked_job(request["data"]))
return {"status": "started"}
return {"status": check_progress()}
@app.async_task # keeps /ping = HealthyBusy while open
async def run_tracked_job(data):
# offload the blocking part so the loop never stalls
await asyncio.to_thread(expensive_sync_call, data)The mental model: the entrypoint's job is to hand off and return; the event loop's job is to keep answering /ping. Anything that holds the loop hostage is a latent session-killer.
Example
Common blocking offenders
Synchronous SDK clients (requests, a blocking DB driver), tight CPU loops (parsing/encoding large data), time.sleep, and synchronous file I/O on large files. Wrap them in asyncio.to_thread(...) (or use async-native libraries) so they run off the event loop. If the agent framework itself is synchronous, run the whole agent call in a thread.
Tip
The Runtime gotchas checklist
The dossier's Runtime gotchas list two items straight from this lesson: 'blocking the entrypoint starves /ping' and 'omitting time_of_last_update.' If a long-running agent misbehaves, check those two before anything else — they account for the majority of mysterious mid-job session deaths.
Duration ceilings and how to read them
Each shape has a hard time ceiling, and they're easy to confuse. As of writing ([VOLATILE] — verify on the live quotas page):
| Limit | Value (as of writing) | Adjustable? |
|---|---|---|
| Synchronous request timeout | 15 min | no |
| Streaming max duration (SSE + WebSocket) | 60 min | no |
| Async job max | 8 hours | no |
| Session idle timeout | 15 min | yes — idleRuntimeSessionTimeout in LifecycleConfiguration |
| Session max duration | 8 hours | yes — maxLifetime |
| Streaming chunk (per event) | 10 MB | no |
A few consequences worth internalizing:
- The 15-minute idle timeout and the 15-minute sync request timeout are different numbers that happen to match today. The idle timeout is what
HealthyBusy+time_of_last_updatedefend against; the sync request timeout is the cap on a single request/response turn. - The idle timeout is one of the few here you can adjust, via
idleRuntimeSessionTimeoutinLifecycleConfiguration. But raising it is not a substitute for correctly reportingHealthyBusy— it just changes how long an idle session survives. - A job approaching the 8-hour async ceiling should checkpoint progress to durable storage (Memory, S3, a database), because session filesystem/memory is not durable and the session will end at the ceiling regardless.
Because these are volatile, design with headroom and read the value at deploy time rather than hardcoding assumptions. The long-running guide and the quotas page are the authoritative, live sources.
Watch out
Session storage is ephemeral
Don't treat in-session memory or the session filesystem as durable across the job's lifetime — when the session ends (at a ceiling, on idle reap, or on completion) it's gone. For results that must survive, write to AgentCore Memory or external storage and let the poll call read from there.
Try it: Build a streaming agent, then a poll-able long-running job — and break it on purpose
Goal: feel the difference between the three work shapes, and reproduce both classic gotchas so you recognize them in production.
Part A — Streaming (SSE).
- Write a
BedrockAgentCoreAppwith anasync def@app.entrypointthatyields. If you have a framework agent, relay it (async for event in agent.stream_async(prompt): yield event); otherwiseyielda few strings with a smallawait asyncio.sleep(0.5)between them. - Run
app.run()and hit it withcurl -N -X POST http://localhost:8080/invocations -H 'Content-Type: application/json' -d '{"prompt":"count to five"}'. Confirm events arrive incrementally (the-Ndisables curl buffering) and that you see onedata:event peryield.
Part B — Long-running with polling.
3. Add an @app.async_task coroutine that does ~2 minutes of fake work (await asyncio.sleep in a loop, updating an in-memory progress value). Make @app.entrypoint branch on request['action']: start schedules the task with asyncio.create_task(...) and returns {"status":"started"} immediately; status returns the current progress.
4. Add a custom @app.ping that returns PingStatus.HEALTHY_BUSY while the task is open and always includes a fresh time_of_last_update (Unix seconds). Start the job, then poll with repeated status calls and watch progress advance.
Part C — Break it (then fix it).
5. Gotcha 1: replace the offloaded work with a blocking time.sleep(900) (or a tight CPU loop) directly in the entrypoint, with no async task. Observe that /ping stops responding promptly while it runs — this is the starvation that gets a real session reaped at 15 min. Fix it with await asyncio.to_thread(...).
6. Gotcha 2: in your @app.ping, delete time_of_last_update from the response (keep HEALTHY_BUSY). Note that you've now recreated the silent session-killer: status says busy, but the missing timestamp would let the idle timeout fire in the cloud. Restore the field.
Reflect (3 sentences): Which shape would you use for a 5-minute report generation, and which for a 3-hour data pipeline? Where would you checkpoint results given that session storage isn't durable, and which volatile duration limit would you re-verify before shipping?
Key takeaways
- 1An `async`-generator `@app.entrypoint` (one that `yield`s) is auto-served as `text/event-stream` (SSE) — each `yield` becomes exactly one SSE `data:` event. No header or SSE library needed.
- 2Long-running work uses one unified Runtime API: respond immediately, keep working in the background, and let the caller poll the same session (reuse the ≥33-char `runtimeSessionId`).
- 3Keep a long job's session alive with `@app.ping` returning `PingStatus.HEALTHY` vs `HEALTHY_BUSY`, and with `@app.async_task` / `app.add_async_task` / `app.complete_async_task` — while ≥1 tracked task is open, `/ping` reports `HealthyBusy`.
- 4GOTCHA: never block the entrypoint. A blocking call starves the shared `/ping` thread, so Runtime kills the session at 15 min during long work. Offload with `asyncio.to_thread(...)` or async-native libraries.
- 5GOTCHA: `/ping` must return `time_of_last_update` (Unix seconds, REQUIRED) and keep it fresh. Omit it and the idle timeout fires even while you report `HealthyBusy` — a silent session-killer.
- 6Duration ceilings (volatile): sync request 15 min, streaming 60 min, async job 8 h (not adjustable); idle timeout 15 min and max lifetime 8 h are adjustable via `LifecycleConfiguration`. Verify on the live quotas page.
- 7Session storage isn't durable — checkpoint long-job results to AgentCore Memory or external storage so the poll call can read them after the session ends.
Quiz
Lock in what you learned
Check your understanding
0 / 4 answered
1.You change your `@app.entrypoint` to an `async def` that `yield`s incremental events from your agent. What does AgentCore Runtime do, and how do `yield`s map to the wire?
2.Your long-running agent reports `PingStatus.HEALTHY_BUSY` from `/ping`, but the session still gets killed around the idle-timeout mark mid-job. What is the most likely cause?
3.Inside `@app.entrypoint` you call a synchronous library that takes ~12 minutes (`result = expensive_sync_call(data)`). Sessions start dying at 15 minutes during long jobs. What's happening and what's the fix?
4.You're tracking background work with `@app.async_task` / `app.add_async_task` / `app.complete_async_task`. What is their effect on `/ping`?
Go deeper
Hand-picked sources to keep learning
The authoritative source for the long-running pattern, /ping, time_of_last_update, and the async/streaming duration behavior taught in this lesson.
The HTTP contract the SDK implements: how the entrypoint and the /ping health endpoint are wired.
Verify the 15-min sync / 60-min streaming / 8-hour async ceilings, idle timeout, max lifetime, and streaming chunk size here before relying on them.
Source for BedrockAgentCoreApp, @app.entrypoint, @app.ping/PingStatus, and @app.async_task / add_async_task / complete_async_task.
Runtime GA deep dive with the serverless/session model context behind streaming and async work.
Runnable examples of streaming and long-running agents to compare against your own entrypoints.