Agentic AI AcademyAgentic AI Academy
AgentCore Runtime/Lesson 5 of 6

Streaming, Long-Running & Async Work

SSE, the 8-hour ceiling, and not starving /ping

Intermediate 13 minBuilder
What you'll be able to do
  • 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
At a glance

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.

  1. 1Beyond quick request/response: three shapes of work
  2. 2Streaming: an async generator auto-serves SSE
  3. 3The long-running pattern: respond fast, keep working, let the user poll
  4. 4The /ping contract: status AND time_of_last_update
  5. 5Gotcha: don't block the entrypoint — you'll starve /ping
  6. 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:

ShapeEntrypointWire behaviorDuration ceiling [VOLATILE]
Synchronousregular def returning a valueone request, one responsesync request timeout 15 min
Streamingasync def generator (yield)text/event-stream (SSE), one event per yieldstreaming max 60 min
Long-running / asyncentrypoint that offloads work + tracks an async taskrespond fast, caller polls; /ping reports HealthyBusyasync 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:

python
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:

bash
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:

  1. Start the long task.
  2. Respond immediately to the caller (e.g. an acknowledgement or a job handle).
  3. Keep working in the background inside the same session.
  4. 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 returns PingStatus.HEALTHY (ready/idle) or PingStatus.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, /ping automatically reports HealthyBusy.

Using the decorator, the SDK flips the status for you for the duration of the task:

python
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 here

If you'd rather manage the lifecycle by hand instead of via the decorator, use the explicit pair:

python
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 remain

Note 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:

json
{ "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):

python
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:

python
# 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:

python
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:

python
@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):

LimitValue (as of writing)Adjustable?
Synchronous request timeout15 minno
Streaming max duration (SSE + WebSocket)60 minno
Async job max8 hoursno
Session idle timeout15 minyes — idleRuntimeSessionTimeout in LifecycleConfiguration
Session max duration8 hoursyes — maxLifetime
Streaming chunk (per event)10 MBno

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_update defend 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 idleRuntimeSessionTimeout in LifecycleConfiguration. But raising it is not a substitute for correctly reporting HealthyBusy — 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).

  1. Write a BedrockAgentCoreApp with an async def @app.entrypoint that yields. If you have a framework agent, relay it (async for event in agent.stream_async(prompt): yield event); otherwise yield a few strings with a small await asyncio.sleep(0.5) between them.
  2. Run app.run() and hit it with curl -N -X POST http://localhost:8080/invocations -H 'Content-Type: application/json' -d '{"prompt":"count to five"}'. Confirm events arrive incrementally (the -N disables curl buffering) and that you see one data: event per yield.

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

  1. 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.
  2. 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`).
  3. 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`.
  4. 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.
  5. 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.
  6. 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.
  7. 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