
Persist Claude Code Turns in Bedrock AgentCore Memory
Translate Claude Code's stream-json output into Bedrock Converse blocks and write each turn to AgentCore Memory, so headless runs show up in your chat UI.
Persist Claude Code Turns in Bedrock AgentCore Memory
Once Claude Code runs headlessly inside a Bedrock AgentCore Runtime instead of on your laptop, a gap opens up: the run happens somewhere you can't watch. A GitHub comment kicks it off, an hour later a PR appears, and everything in between lived and died inside a Firecracker microVM. If you already have a chat UI that renders your other agents' conversations, the obvious question is — why can't Claude Code's turns show up there too, in real time, alongside everything else?
They can. The trick is that AgentCore Memory doesn't care who wrote an event, only what shape it's in. If you translate Claude Code's streaming output into the same Bedrock Converse blocks your harness already writes, a headless Claude Code run lands in the exact same conversation store, readable through the exact same query — no separate read path, no second format to parse.
I've written before about hosting the Claude Code CLI as an AgentCore Runtime with the AgentCoreApplication CDK construct , and about reconstructing a run's transcript from CloudWatch telemetry after the fact. This post is the piece that sits between them: capturing each turn as it happens and persisting it into AgentCore Memory, so there's nothing to reconstruct later — the conversation is already there.
Step one: stream the run, don't wait for it
The first change is on the CLI invocation itself. It's tempting to run Claude Code with
--output-format json , which hands you a single result object when the whole thing finishes. That's fine if all you want is the final answer, but it gives you nothing to persist mid-run — you find out what happened only after it's over.Switching to
--output-format stream-json --verbose changes that. Now the CLI emits one JSON object per line as the run progresses ( NDJSON — newline-delimited JSON):- a
systeminit line,
- an
assistantline for each model turn,
- a
userline carrying tool results,
- and a final
resultsummary line.
So the server buffers stdout and processes it line-by-line, persisting each
assistant and user line to Memory the moment it arrives, while still resolving the invocation's return value from that final result line. Callers that only wanted the final text see no change; you've simply added a live side-channel into Memory. The stream-json output format is documented as part of the Claude Code SDK , and it's the same format the CLI's headless mode exposes.Step two: translate each line into a Converse block
Here's the part that makes the whole thing click. AgentCore Memory stores events through a
CreateEvent call, and the payload it takes is conversational: a role and a content.text . If your harness already writes turns as a serialized array of Bedrock Converse ContentBlock s , then all you have to do is map Claude Code's stream-json blocks onto that same shape and the read side never knows the difference.The mapping is mechanical, one Claude Code block type to one Converse block:
stream-json block | Converse block written |
|---|---|
assistant → { type: "text", text } | { text } |
assistant → { type: "thinking", thinking } | { reasoningContent: { reasoningText: { text: thinking } } } |
assistant → { type: "tool_use", id, name, input } | { toolUse: { toolUseId: id, name, input } } |
user → { type: "tool_result", tool_use_id, content, is_error } | { toolResult: { toolUseId: tool_use_id, status, content: [{ text }] } } |
Each translated turn becomes a
CreateEvent call whose payload[0].conversational is { role, content: { text: JSON.stringify(contentBlocks) } } . Because that's byte-for-byte the shape the harness SDK writes, the handler that already parses session messages — extracting content blocks and flattening them to text — reads a Claude Code turn and a native harness turn identically. No new read code shipped with this at all.A couple of details worth internalizing, because they're the kind of thing that only shows up against real output:
- Drop empty blocks before persisting. The CLI emits
thinkingblocks that are sometimes signature-only placeholders with no text. Persisting those writes noise into the transcript, so filter them out before theCreateEventcall rather than after.
- Seed the transcript with the original prompt. Write the initiating request as its own
USERturn before the CLI even starts. That way the transcript opens on the request that triggered it, exactly the way a normal chat session does — instead of starting mid-thought on the model's first reply.
Step three: key it to the right session
Persisting turns is only half the value; they have to land under the right conversation so the UI you already have surfaces them without a special case. Two identifiers do that work.
The session id comes in as a header, not the body. When you call
InvokeAgentRuntime , the runtimeSessionId is forwarded to the container as the X-Amzn-Bedrock-AgentCore-Runtime-Session-Id request header — it is not in the JSON payload. So the server reads it off the request ( req.get(...) ), with a fallback to a run id in the body. When that session id is the same one your UI opens with ?sessionId=<id> , a run triggered from a GitHub comment writes its Memory events under the exact session the chat page is already watching. The turns just appear.The actor id is the agent, not the human. Memory here is keyed by agent name (a hardcoded
'default' ), matching how the harness reads it — not per-GitHub-user identity. That's what lets a GitHub-triggered run and a UI-triggered run share one conversation view.The memory id and region arrive as environment variables (
AGENTCORE_MEMORY_ID / AGENTCORE_MEMORY_REGION ), because the memory id is a deploy-time CDK token — it can't be baked into a static container config. And the runtime's execution role needs exactly one grant for this: bedrock-agentcore:CreateEvent on the memory ARN.Step four: make persistence best-effort, always
This is the rule that matters most, and it's easy to get wrong: a Memory write must never fail, delay, or retry into the actual Claude Code run. Persistence is observability, not the workload. If a
CreateEvent call throws, log it and swallow it — never let it propagate into the run path. A dropped turn in the transcript is a cosmetic loss; a Claude Code job that dies because a logging call failed is a real one. Every write in this path is fire-and-forget for that reason.The same principle extends to the edges. When a run ends waiting on the user — Claude Code asked a question and stopped — you can persist a terminal marker turn through the same
CreateEvent path, so the UI can detect the pause and re-prompt. There's no distinct "asking for input" event in stream-json (an ask is just a normal assistant text block ending in a question), so a small heuristic on the final message's text is enough to flag it. But even that marker write is best-effort — a missed one just behaves like a normal completion.Why this shape is worth the trouble
You could stand up a separate store for Claude Code's runs, with its own schema and its own read path in the UI. It would work. But every divergence you introduce is a place the two agent types drift apart — a bug fixed on one read path and not the other, a rendering that's subtly different, a query you have to write twice.
Mapping onto the Converse
ContentBlock shape your harness already speaks means Claude Code isn't a special case in your UI — it's just another agent writing to the same Memory resource. The ListEvents query that reads a session back, the block-to-message mapping, the chat rendering: all of it is code you already had. The work is entirely in the translation layer inside the container, and once that's in place, a headless Claude Code run shows up in your chat UI in real time, indistinguishable from a run you started yourself.Takeaways
- Run with
--output-format stream-json --verboseso you get one JSON object per turn to persist as the run progresses, instead of a single object at the end.
- Translate each
stream-jsonblock into a Bedrock ConverseContentBlockand write it viaCreateEvent. Match the shape your harness already writes and you inherit its entire read path for free.
- Key events by the runtime session id (from the
X-Amzn-Bedrock-AgentCore-Runtime-Session-Idheader) and an agent-level actor id, so runs land in the conversation your UI already renders.
- Keep every Memory write best-effort. Log and swallow failures — observability must never break the workload.
If you're setting the foundation for this, the runtime hosting piece covers how the container gets built and deployed, and the CloudWatch telemetry piece covers the observability angle when you'd rather reconstruct after the fact than persist live.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article