Step 3 of 7: State and memory
By the end you will be able to:
- Give the agent memory that survives requests and redeploys
- Explain the difference between local and deployed data
- Show the agent answering from a stored note
Use the Cloudflare account on your team card (also on the Workspace page). No account yet? Ask a host: you can read on, but you can't deploy until it is ready.
The curl and PowerShell examples use $DEMO_URL (macOS / Linux) or $env:DEMO_URL (PowerShell): your demo URL from your team card, set once per terminal in Step 1. Open a new terminal later? Set it again.
Why it matters
- Without memory an agent starts from zero on every request. It cannot track a case, a watch list or a decision.
- Memory lets your agent work over time. That is what you show in the demo.
- It is already built in. The Agents SDK stores state for you, so this step is small.
Memory lives in the Durable Object, right next to ChatAgent, not in the Worker. This is the part of the map to remember. Click any box to see what it does.
Click a box or an arrow to see what it does.
The concepts
Your agent (ChatAgent) is a Durable Object: one instance per name, each with its own embedded SQLite database. Here is how memory works:
- Memory lives with the agent, not in your Worker. The Worker is stateless and can be replaced at any moment, so anything that must last has to be saved inside the agent. The agent's database sits in the same place as the agent, so reading and writing it is fast.
this.sqlis for records. A tagged-template SQL API for tables you create. Use it for lists and history you want to query, such as notes, cases or a watch list, so you can ask for "the last 20 notes" instead of loading everything.this.setState()is for one small live value. One JSON value the SDK saves and syncs to every connected browser. Use it for what the page should show right now, such as a counter or a status. Keep it small, because it is sent to every client on every change; growing data belongs in SQL.- Plain class fields and timers are not durable. They vanish when the agent sleeps or you redeploy. An idle agent can be put to sleep and woken later, so never keep a fact in a variable.
- The model has no memory of its own. On every request it only knows what you send it. Your tools read memory, and what they return goes back to the model. That is how "what did I ask you to remember?" gets a real answer.
- One name, one agent, whoever calls it. The chat page and
POST /api/chatboth talk to the instance nameddefault, so a note saved from one is there in the other. The chat page keeps the conversation; eachcurlcall is a one-off question. A different name would be a different agent with its own database.
Docs: Store and sync state · Agents API · Durable Objects
Go deeper: how long memory lasts, and where else to keep things
You do not need any of this to finish the step. It explains why the tools above are safe to rely on, and what to reach for when your agent's memory grows.
What survives, and what does not
- An agent exists all the time but only runs now and then. It wakes on a request, a WebSocket message or a schedule, runs
onStart(), handles the event, sits idle (about two minutes) and goes to sleep. A redeploy or a crash can stop it at any point, and it starts again on the next event. - These survive:
this.state, everythis.sqltable, scheduled tasks and each WebSocket client's own connection state. - These do not: class fields and other in-memory variables, running timers (
setTimeout,setInterval), requests still in flight and local closures. - That is why the table is created in
onStart().CREATE TABLE IF NOT EXISTSruns on every wake and is safe to repeat. The table is rebuilt only if it is missing; your rows stay. - Work that matters must be saved or recoverable. For long jobs, use a schedule or a Workflow (Step 4) instead of a timer.
Choose where memory lives
- State for what is live, SQL for what is kept. State suits UI state, counters, the active session and configuration. SQL suits history, large collections, relationships and anything you want to query.
- One agent owns its data; shared data goes elsewhere. Each agent's database is private to it, which is also why there is nothing to lock or contend over. A reference list or an audit log that many agents use belongs in D1 or KV (Step 4).
- Memory can feed the prompt. An agent can read its history from SQL, put the relevant rows in the prompt, call the model, and save the answer back for next time.
- React to and check changes.
onStateChanged()is a notification hook (check itssourceso you do not react to your own updates), andvalidateStateChange()can reject a bad update before it is saved.
Docs: Store and sync state · Long-running agents · SQLite storage
Go deeper: the Session API, memory the SDK builds for you
You do not need this to finish the step. Below you build memory by hand with this.sql, because it is the clearest way to see what a Durable Object does. The Agents SDK also ships a ready-made memory layer, the Session API. It is worth knowing about before you build something bigger.
Two kinds of memory
- Conversation history. The messages and tool calls of a session. The starter's
AIChatAgentalready stores these for you, and they survive sleep and redeploys. - Context memory. Persistent blocks that are put into the system prompt and that the agent can read, write, search and load. This is what your
saveNoteandlistNotestools do by hand.
Four kinds of context memory
- Read-only. The agent's identity and instructions, written in code or loaded from a file in R2 or an API. The agent cannot change it, so you can update its personality without redeploying.
- Writable. A scratchpad the agent keeps for itself, stored in SQLite by default. The SDK generates a
set_contexttool for it and enforces a token limit. The content is always in the system prompt, so the agent sees it on every turn without having to call a tool. - Searchable. For a large collection such as a knowledge base, notes or logs. Only a short summary sits in the prompt, and the agent gets a
search_contexttool. The built-in provider uses SQLite full-text search, or you can plug in your own, for example Vectorize. - Loadable (skills). Large documents such as runbooks or checklists. The agent sees a list of titles, loads the one it needs whole with
load_context, and unloads it afterwards. It costs almost nothing until it is loaded. R2-backed skills are built in.
Why this step does not use it
- It is experimental. It is imported from
agents/experimental/memory/session, and the docs say the import paths and details may change. - It overlaps with what the starter already does.
AIChatAgentkeeps the chat history, and the starter builds its prompt and tool list itself, both for the chat page and forPOST /api/chat. Adopting Session means adding its prompt and tools in both places. - A prompt-caching trade-off. The system prompt is frozen so the model provider can cache it. When the agent writes with
set_contextthe data is saved at once, but the model only sees it on a later turn, afterrefreshSystemPrompt(). - What you learn here carries over. Session's default storage is the same SQLite database you use in this step.
When to reach for it
- The agent should learn facts about a person over time without being asked (a writable block).
- The agent needs to find things in a large collection (a searchable block).
- Long conversations need summarising. Session can compact older messages without deleting the originals.
Build it
Prefer to let your coding agent do the typing? Copy this prompt into your agent, in your starter folder.
I am on Step 3 of Build with Cloudflare Stockholm, inside my starter folder. If you have the build-stockholm MCP server, call get_step with step 3.
In src/server.ts, at the top of onStart() (keep the existing OAuth code), create an SQL table: this.sql`CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, created_at TEXT NOT NULL)`.
Add two tools in tools(): saveNote (input: text; inserts a row with the current ISO time; returns { saved: true }) and listNotes (no input; returns the 20 newest rows, newest first). Use this.sql tagged templates, no string concatenation.
Run npm run deploy. Using my demo URL, call POST /api/chat to save "the Malmo store campaign page needs a cache check on Friday", then ask what I asked you to remember. Redeploy and ask again, to prove the note survives.
Do not change anything else in wrangler.jsonc, and do not create or delete resources I did not ask for.
When it works, give me one good prompt to try in the chat page and say what I should see.Paste it into your coding agent: the terminal, the IDE panel or the desktop app all work.
1. Create a table when the agent wakes
In src/server.ts, add these lines at the top of the existing onStart() (keep the OAuth code that is already there):
onStart() {
this.sql`CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
created_at TEXT NOT NULL
)`;
// ...the existing this.mcp.configureOAuthCallback(...) stays below
}
2. Add two tools that use it
Inside tools(), next to your tool from step 2:
saveNote: tool({
description: "Remember something for later. Use when the user asks you to remember or note something.",
inputSchema: z.object({ text: z.string().describe("What to remember") }),
execute: async ({ text }) => {
this.sql`INSERT INTO notes (text, created_at) VALUES (${text}, ${new Date().toISOString()})`;
return { saved: true };
},
}),
listNotes: tool({
description: "List what has been remembered so far, newest first.",
inputSchema: z.object({}),
execute: async () =>
this.sql<{ id: number; text: string; created_at: string }>`
SELECT id, text, created_at FROM notes ORDER BY id DESC LIMIT 20`,
}),
Make them yours: a watch list, a case file, the decisions your agent made. Use synthetic data for this workshop. Your demo URL and chat may be publicly reachable; do not upload real customer, employee or proprietary data.
3. Prove it survives
npm run deploy
Save a note, then ask for it:
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Remember that the Malmo store campaign page needs a cache check on Friday."}'
Remember that the Malmo store campaign page needs a cache check on Friday.
Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"What did I ask you to remember?"}'
What did I ask you to remember?
Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.
Redeploy, then ask again. The note is still there:
npm run deploy
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"What did I ask you to remember?"}'
What did I ask you to remember?
Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.
Check it worked
- The second call answers from the stored note, not from the model's imagination.
- After a redeploy the note is still returned.
- Calling it on your demo URL uses deployed data.
npm run devkeeps its own separate local data.
Stuck?
no such table: notes.onStart()did not create it. Check theCREATE TABLE IF NOT EXISTSis insideonStart()and redeploy.- The model says it cannot remember. It did not call your tools. Say "use saveNote" once, then improve the tool descriptions (same fix as step 2).
- Type error on
this.sql<...>. The generic goes right afterthis.sql, before the backtick. - Local and deployed disagree. They are separate databases on purpose.
Go further
- Give each thing you track its own agent by name (one per device, per market page, per case) and call it with
getAgentByName. - Use
this.setState({ ... })for a small value the chat page should show live, and read it back withthis.state. - SQL API reference: Agents API.
Finished step 3?
Checking your team…
Back: Step 2: First tool. Next: Step 4: Add a primitive.
Need help? Raise a hand for a host, or ask the Mentor dock (it escalates to a host when unsure). Remote: remote help channel to be confirmed by the event owner.