{
  "path": "/posts/2026/stateful-agents/index",
  "site": "at://did:plc:mracrip6qu3vw46nbewg44sm/site.standard.publication/self",
  "$type": "site.standard.document",
  "title": "Stateful Agents and Basic Memory",
  "updatedAt": "2026-02-18T20:26:48.804Z",
  "publishedAt": "2026-02-17T18:03:15.585Z",
  "textContent": "import ChatContainer from '@components/prose/ChatContainer.astro';\nimport ChatMessage from '@components/prose/ChatMessage.astro';\n\nOne of the most limiting factors of LLM agents is they have limited cross-session memory.\nWhen working on software projects, this limitation isn't much of an issue because the agent can search the project and read files to understand the current state and what it needs to do to make the changes you've requested.\nIn a good software project, the code is a lot of the state you need to be successful with an agent.\nHowever, agents become a lot more interesting when you give them access to a persistent file system with instructions or scaffolding to load from that file system when a session begins.\n\nWhile most implementations of memory in consumer products today are relatively opaque, it's quite straightforward to add memory to your agent by giving it access to a file system.\nFrom the perspective of an agent, memory is just a log of what happened.\nYou can write that record manually after each agent turn with something like a stop hook or you can add instructions to CLAUDE.md/AGENTS.md to tell the agent to write a record to a file after each conversation turn.\nOnce you know where you are writing these \"memories\", prompt the agent that it can and should read from these memories as well.\n\nThere are many right ways to do this.\nHere is an extremely simple one.\n\njson\n{\n  \"timestamp\": \"ISO 8601 UTC\",\n  \"summary\": \"1-3 sentence summary of what the user asked and what was done\"\n}\nbash\njq -n -c --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" --arg s \"Summary here\" '{timestamp: $ts, summary: $s}' >> memories.jsonl\nbash\ngrep -i \"keyword\" memories.jsonl | jq -r '.summary'\n\nHere's what interacting with this agent looks like. In this example, the agent uses tool calls to search for prior memories and save new ones:\n\n<ChatContainer model=\"Claude Code: Opus 4.6\">\n  <ChatMessage role=\"user\">\n    hi there, my name is Jack nice to meet you!\n  </ChatMessage>\n\n  <ChatMessage role=\"assistant\">\n    Hi Jack, nice to meet you too!\n\n    <tool-call name=\"Bash\" input={jq -n -c --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" --arg s \"User introduced themselves as Jack.\" '{timestamp: $ts, summary: $s}' >> /Users/danielcorin/dev/lab/min-mem/memories.jsonl}>(No content)</tool-call>\n\n    Let me know if there's anything I can help you with!\n\n  </ChatMessage>\n</ChatContainer>\n\nThis conversation turn results in the following memories.\n\nFrom here, I closed Claude Code and opened a fresh session, then prompted the following.\n\n<ChatContainer model=\"Claude Code: Opus 4.6\">\n  <ChatMessage role=\"user\">\n    who am I?\n  </ChatMessage>\n\n  <ChatMessage role=\"assistant\">\n    I don't have any prior context about you. Let me check if there are any memories that might help.\n\n    <tool-call name=\"Read\" input=\"memories.jsonl\">(1 file)</tool-call>\n\n    Based on our prior conversations, you're Jack. Beyond that, I only know what's been discussed in previous sessions. Is there anything specific you'd like help with?\n\n    <tool-call name=\"Bash\" input={jq -n -c --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" --arg s \"User asked 'who am I?' — responded based on memories.jsonl that they are Jack.\" '{timestamp: $ts, summary: $s}' >> memories.jsonl}>(No content)</tool-call>\n\n  </ChatMessage>\n</ChatContainer>\n\nAnd the memories are updated once more.\n\nMake it a Skill (optional)\n\nIf you like this approach, you can do a refactor of sorts to modularize your approach to memory for the agent using a Skill.\nSkills use a context management technique called progressive disclosure.\nThis is a fancy way of saying the agent only sees the description of the skill by default and when to use it, but not the entire contents of the skill.\nGiven the conversation, the agent decides whether to read the full contents of the skill (markdown and any other additional content).\nOtherwise, this is kept out of the context window to keep the agent focused on the task at hand.\n\nIt's up to you whether or not you want to do this with memory.\nIf you always want the agent to read and write from memory after every conversation turn, there's an argument to be made to leave the implementation in CLAUDE.md, but since most agents have project-specific content in there, let's refactor to a skill.\nI had to make some tweaks to the copy to get the Skill invocation working consistently and writing memories.jsonl to the root of the project.\n\nbash\ntouch memories.jsonl && jq -n -c --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" --arg s \"Summary here\" '{timestamp: $ts, summary: $s}' >> memories.jsonl\njson\n{\n  \"timestamp\": \"ISO 8601 UTC\",\n  \"summary\": \"1-3 sentence summary of what the user asked and what was done\"\n}\nbash\ngrep -i \"keyword\" memories.jsonl | jq -r '.summary'\n\nHere's what happens for the same interaction with the agent that has access to the memory Skill.\n\n<ChatContainer model=\"Claude Code: Opus 4.6\">\n  <ChatMessage role=\"user\">\n    hi there, my name is Jack nice to meet you!\n  </ChatMessage>\n\n  <ChatMessage role=\"assistant\">\n    Hi Jack, nice to meet you!\n\n    <tool-call name=\"Skill\" input=\"memory\">Successfully loaded skill · 5 tools allowed</tool-call>\n\n    <tool-call name=\"Bash\" input='touch /Users/danielcorin/dev/lab/min-mem/memories.jsonl && jq -n -c --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" --arg s \"User introduced themselves as Jack. Greeted them back.\" \"{timestamp: $ts, summary: $s}\" >> memories.jsonl'>(No content)</tool-call>\n\n    Memory saved.\n\n  </ChatMessage>\n</ChatContainer>\n\nWe get our memory written to the file.\n\nAnd now a fresh session.\n\n<ChatContainer model=\"Claude Code: Opus 4.6\">\n  <ChatMessage role=\"user\">\n    who am I?\n  </ChatMessage>\n\n  <ChatMessage role=\"assistant\">\n    Let me search for relevant context first.\n\n    <tool-call name=\"Skill\" input=\"memory\">Successfully loaded skill · 5 tools allowed</tool-call>\n\n    Let me search for relevant context first.\n\n    <tool-call name=\"Bash\" input='touch /Users/danielcorin/dev/lab/min-mem/memories.jsonl && grep -i \"user\\|name\\|who\" /Users/danielcorin/dev/lab/min-mem/memories.jsonl 2>/dev/null | jq -r \".summary\"'>User introduced themselves as Jack. Greeted them back.</tool-call>\n\n    Based on my memory, you're Jack. You introduced yourself in a previous conversation.\n\n    <tool-call name=\"Bash\" input={jq -n -c --arg ts \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" --arg s \"User asked 'who am I?' - recalled from memory that user is Jack.\" '{timestamp: $ts, summary: $s}' >> memories.jsonl}>(No content)</tool-call>\n\n  </ChatMessage>\n</ChatContainer>\n\nAnd again we have two memory entries.\n\nWrapping up\n\nThis is a pretty basic implementation, but it implements the most important part to give an LLM agent something that resembles memory - it reads and writes to a file system that persists beyond the agent's sessions.\nThis approach allows the agent to catch up on what has already happened.\nIn our example, we allow the agent to decide what to read and write to and from memory.\nYou can be more opinionated about this if you want, using a framework like claude-agent-sdk where you have more control over the agent's prompt and can do things like programmatically include the last N entries in the prompt.\n\nFor more on stateful agents, I recommend this post by Tim.",
  "canonicalUrl": "https://www.danielcorin.com/posts/2026/stateful-agents/index"
}