Giving an AI coach a memory: migrating to Bedrock AgentCore
Moving an AI journaling coach from the Amplify AI Kit to a Strands agent on AgentCore Runtime - with a shadow memory pipeline that lets it actually remember you.
Sage is the reflection coach inside Reflect, a small journaling app I build under Sweetcat Software. The idea is modest and the loop is daily: you log a win, answer a guided question, write a morning page. Sage reads it back to you - notices a pattern, celebrates a bit of progress, asks one good question. That was the pitch.
The problem was that Sage had the memory of a goldfish.
Version one ran on the Amplify AI Kit, which got me to a working product fast. But it was stateless across time. Every message re-queried DynamoDB, recomputed “themes” by counting words with the stop-words filtered out, and tracked the conversation behind an ID parked in localStorage. Clear your browser storage or switch phones, and the thread evaporated. Worse than the fragility: Sage had no durable model of you. It could never say “you’ve circled this same goal three Mondays running,” because nothing about Monday survived to Wednesday. The one thing a reflection coach most needs to do - remember a person across weeks - was the one thing it structurally could not do.
This is the story of moving Sage onto Amazon Bedrock AgentCore so that it could. It’s also a fairly honest field report. AgentCore is new, its Memory surface is still preview, and the TypeScript SDKs I leaned on are release candidates (@strands-agents/sdk@1.0.0-rc.4, bedrock-agentcore@0.2.4). Where the shipped build diverged from the design doc I wrote going in - and it diverged in several places - I’ve left the seams showing, because that gap is the most useful part to read.
The shape of the new system
Three pieces do the work:
- A Strands agent in TypeScript, deployed to AgentCore Runtime.
- A shadow memory pipeline fed by DynamoDB Streams, building long-term context out of band.
- A Sage Bridge Lambda behind the existing AppSync + Cognito surface, so the whole swap is a feature flag and the client barely changes.
The detail that makes this tractable is that the app and the agent deploy on completely separate pipelines. The React app, AppSync, and the bridge ship through Amplify (npx ampx). The agent ships through the AgentCore CLI (agentcore deploy), which runs CDK under the hood and stands up the runtime in about three minutes. Those two pipelines never trigger each other. Their entire coupling is three narrow, asynchronous values:
| Value | Published by | Consumed by |
|---|---|---|
SSM /reflect/agentcore/memoryId | memory provisioning (Phase 0) | shadow writer, agent, scripts |
SSM /reflect/agentcore/sageRuntimeArn | the agent’s deploy | the Sage Bridge Lambda |
Invoke payload { actorId, conversationId, prompt } | jointly owned | bridge → agent |
That decoupling is what let me run v1 and v2 side by side for weeks. A frontend deploy couldn’t break the agent; an agent deploy couldn’t break the app. The only ordering rule is the obvious one - the agent’s ARN has to be in SSM before the bridge serves its first request.
The shadow memory pipeline
This is the part I’d build the same way again. Sage’s long-term memory is fed primarily from the journal itself, not from chats with Sage. Every entry a person writes is mirrored into AgentCore Memory by a DynamoDB Streams handler, completely out of band - write-only, additive, with no user-facing surface. By the time the agent ever reads memory, it’s already warm.
The Streams handler renders each entry to a single block of text and writes it as an event. A day of reflection becomes one session (journal-{date}), which lines up neatly with the summarization strategy - you get per-day summaries for free:
const renderEntry = (img: StreamImage): string | null => {
switch (getStr(img, "type")) {
case "WIN":
return (
[getStr(img, "winTitle"), getStr(img, "winDescription")]
.filter(Boolean)
.join(" - ")
.trim() || null
);
case "QUESTION":
return (
[getStr(img, "promptText"), getStr(img, "questionResponse")]
.filter(Boolean)
.join("\n")
.trim() || null
);
case "MORNING_PAGE":
return (getStr(img, "morningPageContent") ?? "").trim() || null;
default:
return null;
}
};
await agentcore.send(
new CreateEventCommand({
memoryId: MEMORY_ID,
actorId, // the Cognito sub - see "Privacy" below
sessionId: `journal-${date}`,
eventTimestamp,
clientToken, // deterministic; see below
payload: [{ conversational: { content: { text }, role: "USER" } }],
}),
);
The tricky part of a shadow pipeline is that journal entries get edited and deleted, and memory has to track those edits without going stale or leaking. I solved it with a tiny link table - entryId → { actorId, sessionId, eventId, updatedAt } - and a deterministic idempotency token:
const clientTokenFor = (entryId, sessionId, text) =>
createHash("sha256").update(`${entryId}|${sessionId}|${text}`).digest("hex");
Identical content yields an identical token, so a replayed Streams record can’t double-write. On an edit, the handler deletes the prior event (looked up via the link) before creating the new one, so a changed entry can never leave two versions of itself in memory. On a delete, it propagates live:
const handleRemove = async (entryId: string): Promise<void> => {
const link = await getLink(entryId);
if (!link) return; // never mirrored; nothing to do
await deleteEvent(link); // remove the source event first…
await deleteLink(entryId); // …then the link, so a failure is retryable
};
The handler returns partial batch failures (batchItemFailures) so a single bad record doesn’t poison the whole batch, and it never logs entry content - only ids, types, and counts. A one-time backfill script replays each existing user’s entries oldest-to-newest, rate-limited, so current users land on a populated memory instead of a blank slate. The phased plan called this “shadow mode,” and it pays off: when you finally flip the agent on, nobody starts from zero.
One thing the AgentCore docs make you internalize the hard way - long-term extraction is asynchronous. Writing an event doesn’t instantly produce a semantic memory; the strategies extract on their own clock, minutes later. Short-term events are retrievable immediately; the durable insights lag. You design copy around it (“Sage is still getting to know you” for brand-new users) and you write integration tests that poll instead of asserting immediate availability. Shadow mode is partly a trick to hide that latency: by cutover, the extraction has long since caught up.
Grounding without a session manager
Here’s the first place the build diverged from the plan. My design doc assumed the Strands TypeScript “AgentCore Memory session manager” would inject relevant memories into the agent’s context automatically. It wasn’t ready for what I needed, so the agent hand-rolls grounding by calling the Memory data-plane directly. Honestly, I prefer the result - it’s explicit about exactly what context the model sees.
At the start of each turn the agent pre-fetches a handful of relevant memories across the actor’s three namespaces and folds them into the system prompt. And it’s best-effort by design: a memory hiccup must never block the reply.
async function prefetchGrounding(
actorId: string,
message: string,
): Promise<string> {
if (!MEMORY_ID) return "";
const snippets: string[] = [];
try {
for (const namespace of namespacesFor(actorId)) {
// semantic / summary / pref
const res = await agentcore.send(
new RetrieveMemoryRecordsCommand({
memoryId: MEMORY_ID,
namespace,
searchCriteria: { searchQuery: message, topK: 5 },
maxResults: 5,
}),
);
for (const r of res.memoryRecordSummaries ?? []) {
if (r.content?.text) snippets.push(`- ${r.content.text}`);
}
}
} catch {
return ""; // grounding is best-effort; never block the reply
}
if (!snippets.length) return "";
return (
`\n\nWhat you know about this person from their past reflections ` +
`(grounding - do not invent beyond this):\n${snippets.join("\n")}`
);
}
That “do not invent beyond this” matters more than its length suggests, and it’s backed by a system prompt where groundedness is non-negotiable: Sage may only state things about your history that appear in retrieved memory or tool results, and if it doesn’t have something, it says so rather than fabricating a plausible memory. For a coach that’s reflecting your own life back at you, a confident hallucination here is a betrayal.
Grounding is deliberately layered. Beyond the pre-fetch, the agent also gets a recallMemory tool for deeper on-demand lookups (“when did I last feel like this?”), plus the two deterministic analysis Lambdas from v1, reused as-is:
export function buildTools(actorId: string) {
const recallMemory = tool({
name: "recallMemory",
description:
"Semantic search over the person's own past reflections… " +
"Returns only things the person actually recorded.",
inputSchema: z.object({
query: z.string(),
topK: z.number().int().min(1).max(20).optional(),
}),
callback: async ({ query, topK }) => ({
results: await searchMemory(actorId, query, topK ?? 5),
}),
});
// getWeeklySummary + analyzePatterns invoke the existing Lambdas directly,
// passing actorId explicitly (no AppSync identity when the agent calls them).
// getSupportResources backs the crisis path.
return [recallMemory, getWeeklySummary, analyzePatterns, getSupportResources];
}
And there’s the second divergence. The plan was to put an AgentCore Gateway in front of those Lambdas to expose them as MCP tools. In practice that was ceremony I didn’t need - the agent just calls the Lambdas directly as Strands tools, passing the actorId explicitly (there’s no AppSync identity when the agent is the caller). The agentCoreGateways array in my config is empty and the app is better for it. After the model finishes streaming, the completed turn is written back into memory as its own event (sage-{conversationId}), so conversations build long-term memory too - not just journal entries.
The bridge: streaming behind a feature flag
The Sage Bridge Lambda is the seam that made all of this swappable. It resolves sendSageMessage from AppSync, and its first job is identity: actorId is always the validated Cognito sub, never a value from the client. It then enforces membership in a sage-v2-beta Cognito group - the client gates on this too, but the bridge enforces it as defense in depth - invokes the runtime with streaming, and relays each chunk back out over an AppSync subscription.
Two real gotchas live in this file. First, AgentCore’s runtimeSessionId must be 33–256 characters, which a tidy sage-{conversationId} will happily violate, so it gets padded. Second, the runtime streams Server-Sent Events, and the bridge has to reassemble them across arbitrary Uint8Array boundaries:
const runtimeSessionIdFor = (conversationId: string): string =>
`sage-${conversationId}`.padEnd(33, "0").slice(0, 256); // 33–256 char constraint
async function* iterateSseText(stream: unknown): AsyncGenerator<string> {
const iterable = stream as AsyncIterable<Uint8Array> | undefined;
if (!iterable?.[Symbol.asyncIterator]) return;
const decoder = new TextDecoder();
let buffer = "";
for await (const part of iterable) {
buffer += decoder.decode(part, { stream: true });
let idx: number;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
// SSE events are \n\n-delimited
const segment = textFromSseEvent(buffer.slice(0, idx));
buffer = buffer.slice(idx + 2);
if (segment) yield segment;
}
}
const tail = textFromSseEvent(buffer);
if (tail) yield tail;
}
Each parsed segment is published via a publishSageChunk mutation, which fans out to the onSageChunk subscription the client is watching - so tokens render incrementally, exactly as they did under the AI Kit. A done signal goes out in a finally, so the client always gets a clean end-of-stream even if the relay throws. Because every AgentCore detail is sealed inside this one Lambda, the v1↔v2 switch really is just a feature flag.
Where the plan met reality
A few more rough edges, nearly all of them downstream of how new the platform still is:
- TypeScript means a container. AgentCore’s code-zip deploy path is Python-only, so a TypeScript agent ships as a container on arm64 (
NODE_22), built by CodeBuild. It’s the less obvious of the two deploy routes, and easier to design around up front than to discover halfway in. - Dependency resolution is fragile on release-candidate SDKs.
bedrock-agentcoreand my direct dependency on@strands-agents/sdkdisagreed on which SDK version to resolve, which took an npmoverridesblock to pin together. And since every API shape on an RC is provisional, I verified each command name against the installed package rather than the docs, and pinned versions hard. - Setting the model directly retired an old workaround. Under the AI Kit I’d hit a cross-region inference issue with the Claude 4.x models and patched around it with a CDK Aspect. Configuring the model directly through AgentCore - Claude Sonnet 4.6, via an inference profile - let me drop that workaround entirely.
- The generator scaffolds a demo agent. The AgentCore CLI starts you from a working example that you then adapt. It’s a good workflow, but the scaffold ships with a sample MCP client pointed at a demo endpoint, so part of adapting it is removing what you don’t use before deploy.
None of these are dealbreakers. They’re the ordinary tax on building against a service while it’s still settling, and AgentCore’s primitives - Runtime, Memory, Identity - were coherent enough that the tax felt fair.
Privacy is the architecture, not a feature
I want to be clear that the memory work and the privacy work were the same work. This is the most intimate data Reflect holds, and the obligations are hard requirements, not a roadmap:
- Memory is opt-in. Building a long-term model of someone’s journal is a distinct consent from “use Sage,” with its own toggle, defaulting off.
- Memory is legible. Users can read, edit, and delete what Sage remembers about them. It’s the strongest trust feature in the product - and a real differentiator, too.
- Deletion propagates. A deleted entry removes its source memory event live (that
handleRemovepath), and account deletion runs a purge over already-extracted records. Memory that can’t be fully deleted is a liability. - There’s a safety floor. The system prompt forbids diagnosis and clinical labels, stays gentle rather than amplifying on low-mood entries, and - overriding everything else - drops out of “coaching” entirely into a crisis path that surfaces verified support resources. That path is a launch gate for real users, not a follow-up.
- Nothing sensitive is logged. Entry and conversation content never reaches a log line - ids and counts only.
What I’d tell someone starting this today
If your agent genuinely needs to remember a person across sessions, AgentCore earns its keep - Memory is the primitive that matters most here, and the rest of the platform exists to support it. If you mostly need single-session tool-calling against your own data, the Amplify AI Kit is still the faster, cheaper, less fiddly call, and there’s no shame in it; it carried Sage to a real product first.
Three things I’d repeat without hesitation. Warm your memory in the shadows - stream your existing data in out of band and backfill before any agent reads it, so day one isn’t a cold start and the async-extraction lag is invisible. Isolate the new runtime behind one Lambda - the bridge made v1 and v2 coexist behind a flag and turned a scary migration into a boring toggle. And pin everything, verify every API shape - on preview services with release-candidate SDKs, the docs are aspirational and your package-lock.json is the only source of truth.
Sage can finally say “you’ve come back to this same question three weeks running.” Getting it to remember that was most of the work - and, as it happened, most of the point.