Your AgentCore Session Is Not a Conversation. Confusing the Two Is How Continuity Dies.

Behind AgentCore Runtime, 'session' means a microVM lease and a conversation ID; forgetting to join them produces silent amnesia, and the 8-hour limit is a lease that does not stop your loop for you.

Rick Hightower

Cover image for “Your AgentCore Session Is Not a Conversation. Confusing the Two Is How Continuity Dies.” by Rick Hightower

Streaming, two meanings of "session," and why the 8-hour Runtime limit is a lease that will never stop your agent for you.

Your agent answers every follow-up like a stranger, and the only signal is a bug report that says it forgets sometimes. Streaming is fine; confusing the two meanings of session is how continuity dies.

In this article: You will learn the runtime contract that sits between your agent loop and Amazon Bedrock AgentCore Runtime. We cover how async generators become SSE, how to read the Runtime session ID, why AgentCore's session and your framework's session are different objects, where in-process checkpoints go to die, and which four stopping conditions still live only in your code. By the end, streaming, identity, durable state, and loop bounds are one coherent contract.

You ship a first deploy that treats AgentCore like a function call with a JSON body. Send a prompt, wait, get an answer. It works. It is the right first deploy.

It also fails the moment anything real happens. Your agent runs for four minutes, and the analyst staring at a spinner assumes it hung. She asks a follow-up question, and the agent has never met her. You read the 8-hour session limit, conclude you have plenty of headroom, and move on, which is the most expensive misreading available on this platform.

All three are the same problem wearing different clothes. Your loop now runs behind a hosting contract you do not control, and that contract has opinions about time, identity, and death that your loop knows nothing about. This article makes the two agree, starting with streaming, then with the word "session," which in this stack means two completely different things.

Streaming, properly

BedrockAgentCoreApp dispatches on the shape of your entrypoint. An async generator gets bridged to server-sent events through a worker loop. That is what turns a long run into something a human can watch.

The naive version returns one JSON blob at the end. The client sees nothing for four minutes and then everything at once. Technically correct, operationally hostile.

Two return paths from one entrypoint: a single JSON return freezes the client for minutes, while an async generator bridged to SSE streams tokens and tool events as the agent works.

The Claude Agent SDK's query() is already an async generator, so streaming is mostly a matter of not swallowing it:

@app.entrypoint
async def market_intel_agent(request):
    prompt = request.get("prompt", "")
    options = ClaudeAgentOptions(allowed_tools=["Read", "Bash", "Glob"])
    async for message in query(prompt=prompt, options=options):
        yield message

DeepAgents gives you the same through .astream(), and something better through .astream_events(), which exposes the graph's internals at token granularity:

@app.entrypoint
async def handler(request):
    prompt = request.get("prompt", "")
    async for event in agent.astream_events(
        {"messages": [("user", prompt)]}, version="v2"
    ):
        kind = event["event"]
        if kind == "on_chat_model_stream":
            chunk = event["data"]["chunk"].content
            if chunk:
                yield chunk
        elif kind == "on_tool_start":
            yield f"\n[tool: {event['name']}]\n"

Read what that second branch does. It surfaces tool calls to the client as they happen, which is the difference between an agent that appears frozen and an agent that visibly reads a file, runs a query, and thinks. For a loop that runs for minutes, that is not a nicety; it is the entire user experience.

There is also /ws. BedrockAgentCoreApp registers three routes, not two: POST /invocations, GET /ping, and WebSocketRoute("/ws"). SSE is one-directional and fine for most agents. Reach for the WebSocket when the client needs to talk back mid-run, which in practice means human-in-the-loop approvals.

If your client wants one JSON blob after all, you can still use a generator and yield exactly once. That is a cleaner shape than switching handler types, because it keeps the dispatch behavior consistent across your codebase.

The context you did not ask for

Your entrypoint has been taking one parameter. It can take two.

@app.entrypoint
async def handler(request, context):
    session_id = context.session_id
    ...

AgentCore inspects your signature, notices the second parameter, and passes a RequestContext carrying session_id and headers. Nothing to register, nothing to import. The detection is literally a function named _takes_context in the Runtime's app.py.

There is a second route to the same information, which matters when you need the value somewhere that does not have the parameter in scope:

from bedrock_agentcore.runtime import BedrockAgentCoreContext

session_id = BedrockAgentCoreContext.get_session_id()

That is a contextvar, populated from the X-Amzn-Bedrock-AgentCore-Runtime-Session-Id header that the Runtime injects. The same contextvars that serve_a2a relies on are available inside a plain entrypoint, which is why deep tool code can reach the session ID without you threading it through five call frames.

Both mechanisms hand you the same string. Use the parameter when you have it, and the contextvar when you are three layers down.

Now, what is that string?

Two sessions, and the gap between them

Here is the part that costs people a week.

AgentCore's session is a microVM lease. It is a slice of compute with isolated CPU, memory, and filesystem, with states Active, Idle, and Terminated. It dies after 15 minutes of inactivity, at 8 hours maximum, or when it goes unhealthy. It knows nothing about conversations. It is a container with a countdown.

Your framework's session is conversation state. In the Claude Agent SDK, it is the session_id that arrives on the SystemMessage at init, and you resume it with ClaudeAgentOptions(resume=session_id). In DeepAgents, it is thread_id, passed through config["configurable"]["thread_id"], keying a LangGraph checkpointer. It knows nothing about compute. It is a conversation with a memory.

A mindmap of two unrelated meanings of session: AgentCore's microVM lease with Active, Idle, and Terminated states, versus the framework conversation keyed by session_id or thread_id, joined only by a string you pass by hand.

Different objects, different lifetimes, and the only thing connecting them is an ID you remembered to pass:

from bedrock_agentcore.runtime import BedrockAgentCoreContext

@app.entrypoint
async def handler(request):
    prompt = request.get("prompt", "")
    session_id = BedrockAgentCoreContext.get_session_id() or request.get(
        "session_id", "default"
    )
    config = {"configurable": {"thread_id": session_id}}
    result = await agent.ainvoke({"messages": [("user", prompt)]}, config=config)
    yield result["messages"][-1].content

One line does the join: the AgentCore session ID becomes the DeepAgents thread ID, so one lease equals one checkpointed conversation. The Claude Agent SDK equivalent threads context.session_id into resume.

A sequence diagram of the join: Runtime injects a session header, the entrypoint maps it to thread_id or resume, a durable checkpointer reloads prior state, and SSE events stream back to the client.

Miss that line and nothing breaks loudly. The agent answers. It just answers as a stranger every time. The bug report says "it forgets sometimes," and you cannot reproduce it locally because locally there is only ever one session.

Gotcha: the two sessions are joined by a string you pass by hand, and forgetting it produces amnesia rather than an error. Write the join first, before the interesting code, in every entrypoint you build.

The trap inside the trap

Look again at that snippet and ask where the checkpointer lives.

The obvious answer is the one in every tutorial:

from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()

Now put that in a per-session microVM. The memory in InMemorySaver is the microVM's memory. The microVM terminates after 15 minutes of inactivity. Your checkpointed conversation is inside the thing that just died.

So the join works, the thread ID is threaded correctly, the code is right, and continuity still evaporates, because you persisted state into a container whose entire design premise is that it is disposable. The DeepAgents docs tell you to use Postgres or DynamoDB in production. In this environment, that guidance is not about scale. It is about the fact that your process has a countdown on it.

The Claude Agent SDK has the identical problem from the other direction: its sessions live in-process by default, so resume works beautifully within one microVM and means nothing across a cold start.

Durable memory in this architecture is not an enhancement you add when you want the agent to feel smart. It is what makes continuity survive a lease expiring, which it will, on a schedule, in production, at 3 a.m.

In production: any state that must outlive one microVM lives in AgentCore Memory or an external store, full stop. If it lives in your process, you have not persisted it; you have cached it in something with a countdown.

The lifecycle is a lease, not a stopping condition

Which brings us to the misreading that deserves bluntness.

Developers see "up to 8 hours lifetime" and read it as a timeout. It is not a timeout. It is a lease, and the difference is who it protects.

A timeout protects your loop from itself. A lease protects AWS's fleet from your loop. The 8-hour ceiling will stop a runaway agent in roughly the way running out of gas stops a car with a stuck accelerator: eventually, expensively, and not because anything worked.

Recall the four stopping conditions every production loop needs: maximum turns, token budget, no-progress detection, and goal-achievement check. Check them against what AgentCore gives you.

None of them. AgentCore gives you zero of the four.

It cannot give you any of them, and that is not a gap in the product. The Runtime cannot see your loop. It sees an async generator that yields things. It has no concept of a turn, no access to your token counts, no way to notice that you have called the same tool with the same arguments eleven times, and no opinion about whether the output is correct. A boundary clean enough to host any framework is a boundary opaque enough to see nothing.

So the four conditions stay in your loop, and the frameworks are where you get them.

A flowchart splitting responsibility: AgentCore supplies only the microVM lease that bounds blast radius, while max turns, spend budget, no-progress detection, and goal checks remain in your loop and frameworks.

The Claude Agent SDK ships two of the four outright. max_turns caps iterations and returns a ResultMessage with subtype error_max_turns when exceeded, and its default is None, which means no limit. Set it. More unusually, max_budget_usd is a real whole-run spend ceiling across all turns, returning error_max_budget_usd when hit. No other reviewed framework ships that off the shelf, and in a hosted, unattended, hours-long context it is arguably the most valuable parameter in the SDK. Set both in every deployed agent.

DeepAgents inherits LangGraph's recursion_limit, which is a safety net rather than a turn policy, and it has no whole-run budget parameter. You build that one: sum input and output tokens across turns and halt when the budget breaks. LangGraph's checkpointing also gives you the state you need for no-progress detection, by hashing the tool-call sequence and halting when it repeats N times.

The fourth condition, goal achievement, is nobody's off-the-shelf feature, because it requires a verifier you wrote.

Pattern check: the microVM is the isolation pattern and the 8-hour lease is its outer bound. Your four stopping conditions are control-plane policy and live entirely in your code. The platform bounds the blast radius; only you bound the loop. A runaway agent inside a perfectly isolated microVM is still a runaway agent with a credit card.

Reading the lifecycle correctly

With the lease framing in place, the three states become useful rather than trivia.

Active means work is happening. Idle means the microVM is alive, nothing is running, and the 15-minute clock is ticking. Terminated means the compute is gone, along with anything you left in it.

A state diagram of the AgentCore microVM lifecycle: Active while work runs, Idle while nothing runs and the 15-minute clock ticks, Terminated on inactivity, 8-hour max, or unhealthy status, with in-process state dying on Terminated.

Idle is the state that deserves your attention, because it is where a specific class of agent design goes to die. An agent paused for a human approval is idle. An agent waiting on a slow external job is idle. Fifteen minutes of that, and the platform reclaims your container, which means human-in-the-loop designs need the human to be fast, or the wait to be durable and resumable rather than a blocked call inside a doomed process.

That interacts with the streaming choice from earlier in a way worth noticing. A WebSocket connection held open while a human decides whether to approve a purchase is a session burning down its inactivity clock while a person has lunch. Design the pause as a durable checkpoint plus a fresh invocation, not as a held connection.

Resuming, mechanically, is the one line you already wrote: send a new invocation with the same session ID, and if your checkpointer is durable, the conversation continues. If the previous microVM was terminated, you get a new one and the conversation continues anyway, because the state was never in the container. That is the design working as intended.

Do this today

  • Yield from your entrypoint. Convert any handler that returns one blob into an async generator so BedrockAgentCoreApp can stream SSE, and surface tool starts as well as tokens.
  • Read the Runtime session ID. Accept the second context parameter or call BedrockAgentCoreContext.get_session_id(), then log it on every invocation.
  • Join platform identity to framework state. Map the AgentCore session ID into DeepAgents thread_id or Claude Agent SDK resume in every entrypoint, before you write anything interesting.
  • Move checkpoints off the microVM. Replace InMemorySaver and in-process session stores with Postgres, DynamoDB, AgentCore Memory, or another store that survives Terminated.
  • Set loop bounds the platform will never set. Enable max_turns and max_budget_usd on Claude Agent SDK agents, or build an equivalent whole-run budget and no-progress halt for DeepAgents.

The contract, fully signed

A loop that streams its progress, knows who it is talking to, threads the platform's session identity into its own conversation state, keeps that state somewhere that survives the container, and stops itself on its own terms rather than waiting for a lease to expire.

That is the runtime contract. It is the last purely boundary-shaped piece of harness work before capability takes over: tools, a sandbox, a browser, durable memory, identity, traces, and workers.

"Session" will keep meaning two things on this stack. The bug that wastes a week is forgetting that, and the fix is one deliberate join plus a store that outlives the microVM. Your agent can answer like a stranger forever, or it can answer like itself after a cold start. The Runtime will not choose for you.