M
MeshWorld.
Agent Skills Agentic AI Tutorial Node.js Memory SQLite Intermediate 9 min read

Agent Skills with Memory: Persisting State Between Chats

By Vishnu Damwala

I asked my agent “what’s my preferred programming language?” for the fourth time in a week.

It had no idea. Every conversation started from scratch. I had explained my setup, my preferences, and my project context over and over — and the agent forgot everything the moment the conversation ended.

This is the default behavior for every AI agent that doesn’t have a memory skill. The model itself has no persistent state. It knows what you told it this session. Nothing more.

A remember and recall skill fixed this in an afternoon. The agent now retains context across sessions indefinitely — and I never have to re-explain myself.


Why memory is a skill, not a system prompt trick

The instinct when you want an agent to remember something is to put it in the system prompt:

You are an assistant for Vishnu. He prefers Python. He works on a blog. He doesn't like meetings before 10am.

This works — until you have 50 preferences to track. Until the context window fills up. Until you want to update a preference mid-conversation and have it persist. Until you want to check what the agent currently knows.

Memory as a skill is different. The agent actively decides to store information when it seems useful, and retrieves it when relevant. You don’t have to predict what to put in the system prompt. The agent builds its own context over time.


The two primitives

Everything starts with two tool functions:

remember(key, value)  // store something
recall(key)           // retrieve something

With an optional third for visibility:

list_memories()       // what does the agent currently know?

Let’s build them.


Level 1 — JSON file store

Simple, portable, human-readable. A single JSON file on disk.

// memory-store.js
import { readFile, writeFile, rename } from "node:fs/promises";
import { existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const MEMORY_FILE = join(process.env.HOME, ".agent-memory.json");

async function readMemory() {
  if (!existsSync(MEMORY_FILE)) return {};
  try {
    return JSON.parse(await readFile(MEMORY_FILE, "utf8"));
  } catch {
    return {};
  }
}

// Atomic write: write to temp file first, then rename
// This prevents corruption if the process crashes mid-write
async function writeMemory(data) {
  const tmp = join(tmpdir(), `agent-memory-${Date.now()}.json`);
  await writeFile(tmp, JSON.stringify(data, null, 2), "utf8");
  await rename(tmp, MEMORY_FILE);
}

export async function remember({ key, value }) {
  if (!key || value === undefined) {
    return { error: "Both key and value are required." };
  }

  const memory = await readMemory();
  memory[key] = { value, updatedAt: new Date().toISOString() };
  await writeMemory(memory);

  return { stored: true, key, value };
}

export async function recall({ key }) {
  if (!key) return { error: "Key is required." };

  const memory = await readMemory();
  const entry = memory[key];

  if (!entry) return { found: false, key, message: `No memory found for "${key}".` };
  return { found: true, key, value: entry.value, updatedAt: entry.updatedAt };
}

export async function list_memories() {
  const memory = await readMemory();
  const entries = Object.entries(memory).map(([key, entry]) => ({
    key,
    value: entry.value,
    updatedAt: entry.updatedAt
  }));
  return { count: entries.length, memories: entries };
}

export async function forget({ key }) {
  if (!key) return { error: "Key is required." };
  const memory = await readMemory();
  if (!memory[key]) return { deleted: false, message: `No memory found for "${key}".` };
  delete memory[key];
  await writeMemory(memory);
  return { deleted: true, key };
}

The atomic write pattern (write to temp file → rename) is important. If your process crashes while writing, the original file stays intact. Without it, a partial write produces corrupt JSON and you lose everything.


Level 2 — SQLite with better-sqlite3

For more data, fuzzy search, or multiple agents sharing the same store, upgrade to SQLite.

npm install better-sqlite3
// memory-sqlite.js
import Database from "better-sqlite3";
import { join } from "node:path";

const DB_PATH = join(process.env.HOME, ".agent-memory.db");
const db = new Database(DB_PATH);

// Create table if it doesn't exist
db.exec(`
  CREATE TABLE IF NOT EXISTS memories (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    conversation_id TEXT,
    updated_at TEXT DEFAULT (datetime('now'))
  )
`);

export function remember({ key, value, conversationId }) {
  if (!key || value === undefined) return { error: "Key and value are required." };

  db.prepare(`
    INSERT INTO memories (key, value, conversation_id, updated_at)
    VALUES (?, ?, ?, datetime('now'))
    ON CONFLICT(key) DO UPDATE SET
      value = excluded.value,
      conversation_id = excluded.conversation_id,
      updated_at = excluded.updated_at
  `).run(key, String(value), conversationId ?? null);

  return { stored: true, key, value };
}

export function recall({ key }) {
  if (!key) return { error: "Key is required." };
  const row = db.prepare("SELECT * FROM memories WHERE key = ?").get(key);
  if (!row) return { found: false, key, message: `No memory found for "${key}".` };
  return { found: true, key, value: row.value, updatedAt: row.updated_at };
}

export function list_memories({ search } = {}) {
  let rows;
  if (search) {
    rows = db.prepare("SELECT * FROM memories WHERE key LIKE ? OR value LIKE ? ORDER BY updated_at DESC")
      .all(`%${search}%`, `%${search}%`);
  } else {
    rows = db.prepare("SELECT * FROM memories ORDER BY updated_at DESC").all();
  }
  return { count: rows.length, memories: rows.map(r => ({ key: r.key, value: r.value, updatedAt: r.updated_at })) };
}

export function forget({ key }) {
  if (!key) return { error: "Key is required." };
  const info = db.prepare("DELETE FROM memories WHERE key = ?").run(key);
  if (info.changes === 0) return { deleted: false, message: `No memory found for "${key}".` };
  return { deleted: true, key };
}

SQLite runs synchronously with better-sqlite3, so no async/await needed. It handles concurrent reads safely, but avoid concurrent writes from multiple processes to the same file.


Tool definitions — writing them so the model stores proactively

The key difference between a memory skill the model uses actively and one it ignores is the description. You want the model to store preferences automatically, not just when you explicitly say “remember this.”

const memoryTools = [
  {
    name: "remember",
    description:
      "Store a piece of information for future reference. " +
      "Use this proactively when the user mentions: preferences, settings, their name, " +
      "work context, project details, habits, or anything they might want you to recall later. " +
      "Use clear, descriptive keys like 'preferred_language', 'project_name', 'timezone'.",
    input_schema: {
      type: "object",
      properties: {
        key: { type: "string", description: "Short descriptive key, e.g. 'preferred_language'" },
        value: { type: "string", description: "The value to store" }
      },
      required: ["key", "value"]
    }
  },
  {
    name: "recall",
    description:
      "Retrieve a stored memory by key. " +
      "Use this when the user asks about their preferences, settings, or anything " +
      "they may have told you before — especially if you are not sure of the answer.",
    input_schema: {
      type: "object",
      properties: {
        key: { type: "string", description: "The key to look up" }
      },
      required: ["key"]
    }
  },
  {
    name: "list_memories",
    description:
      "List all stored memories. Use when the user asks what you remember about them, " +
      "or wants to see or clear their stored information.",
    input_schema: { type: "object", properties: {} }
  },
  {
    name: "forget",
    description:
      "Delete a stored memory by key. Use when the user asks you to forget something specific.",
    input_schema: {
      type: "object",
      properties: {
        key: { type: "string", description: "The key to delete" }
      },
      required: ["key"]
    }
  }
];

The phrase “Use this proactively when the user mentions…” is what makes the model store information without being asked. Without it, the model treats remember as a command-only tool.


Full working demo

Here’s a script showing memory persisting across two separate sessions:

// memory-agent.js
import Anthropic from "@anthropic-ai/sdk";
import { remember, recall, list_memories, forget } from "./memory-sqlite.js";

const client = new Anthropic();
const toolFunctions = { remember, recall, list_memories, forget };

async function chat(userMessage, sessionLabel) {
  console.log(`\n--- ${sessionLabel} ---`);
  console.log(`User: ${userMessage}`);

  const messages = [{ role: "user", content: userMessage }];
  let response = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    tools: memoryTools,
    messages
  });

  while (response.stop_reason === "tool_use") {
    const toolBlock = response.content.find(b => b.type === "tool_use");
    const fn = toolFunctions[toolBlock.name];
    const result = fn ? await fn(toolBlock.input) : { error: "Unknown tool" };

    console.log(`[Tool: ${toolBlock.name}(${JSON.stringify(toolBlock.input)})] →`, result);

    messages.push(
      { role: "assistant", content: response.content },
      { role: "user", content: [{ type: "tool_result", tool_use_id: toolBlock.id, content: JSON.stringify(result) }] }
    );
    response = await client.messages.create({
      model: "claude-sonnet-4-6", max_tokens: 1024, tools: memoryTools, messages
    });
  }

  const answer = response.content[0].text;
  console.log(`Agent: ${answer}`);
  return answer;
}

// Session 1: tell the agent something
await chat("I prefer Python over JavaScript. My timezone is IST.", "Session 1");

// Session 2 (simulated): new conversation, but memory persists
await chat("What programming language do I prefer?", "Session 2");
await chat("What's my timezone?", "Session 3");

Run it twice. In the first run, the agent stores your preferences. In any subsequent run, it retrieves them — even though the conversation history is empty.


What NOT to store

Memory skills are powerful but they require care:

  • Credentials and tokens — never store API keys, passwords, or tokens in memory. They’ll end up in plain text on disk.
  • Full conversations — memory is for facts and preferences, not transcripts. Use a separate conversation log for that.
  • Large blobs — memory is for small, structured facts. Don’t store entire documents. Use file system skills for that.
  • PII without consent — if you’re building for multiple users, make sure users know what’s being stored.

For multi-user setups, namespace your keys with a user ID: vishnu:preferred_language instead of just preferred_language.


How this relates to OpenClaw

If you use OpenClaw, its built-in memory system works on exactly this model — the agent has remember and recall capabilities built in, and memory lives as Markdown files in ~/.openclaw/agents/yourname/memory/.

The difference: OpenClaw’s memory is human-readable Markdown, while our SQLite version is queryable but requires tooling to inspect. Both approaches are valid. Markdown wins for portability and transparency; SQLite wins for search at scale.

Learn about OpenClaw’s memory system: How OpenClaw Memory Works


What’s next

Give your agent file system access: File System Skills: Let Your Agent Read and Write Files

Chain memory with other skills: Chaining Agent Skills: Research, Summarize, and Save

Multi-agent memory sharing: Multi-Agent Systems Explained: When One AI Isn’t Enough