My Claw: Build Your AI System with Claude Code
Memory
The claw learns what you care about, who matters, and how to operate. A self-curating memory system that runs in the background. The operator never touches it.
You told the claw you prefer short answers. Tomorrow it asks "would you like a summary or the full details?" You told it to always check 10 days of calendar. Next week it checks 2 days again.
Sessions don't persist. The claw forgets everything between container restarts. Knowledge doesn't compound. Every conversation starts from zero.
This lesson fixes that. You build a memory system that runs in the background, analyzes your interactions, and curates a briefing document the claw reads before every response. You never manage it. You just talk to the claw and it gets better.
What you're building
Three new pieces layered onto the channel system:
The loop:
interact → log → analyze (Sonnet) → curate MEMORY.md → inject on next turn → better interactionsThe operator never sees the machinery. They just notice the claw stops asking redundant questions and starts anticipating what they need.
The plugin
Claude Code plugins bundle hooks, skills, and settings into a portable unit. This one does one thing: inject the current datetime on every user turn.
.claude-plugin/
plugin.json
hooks/
hooks.json
bin/
inject-datetime.sh{
"name": "my-claw",
"description": "Personal claw plugin: datetime injection, memory context",
"version": "0.1.0"
}{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/bin/inject-datetime.sh"
}
]
}
]
}
}#!/bin/bash
NOW=$(date '+%Y-%m-%d %H:%M %Z (%A)')
cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "[Current time: ${NOW}]"
}
}
EOFUserPromptSubmit fires before Claude processes any message. The hook outputs JSON with additionalContext, which gets injected into the conversation. Now the claw knows it's 2:30pm on a Wednesday when you ask "am I free this afternoon?"
The claw loads the plugin via --plugin-dir in the claude -p call. One flag, datetime on every turn.
Lock down MCP
Your host Claude Code installation has MCP servers (Gmail, Google Calendar) that auto-discover via OAuth. Inside the container, the claw inherits those and starts suggesting "connect your Google Calendar." That's wrong. The claw uses gws via Bash. Nothing else.
Add --strict-mcp-config to the claude args in claw.mjs:
const args = [
"-p", fullPrompt,
"--output-format", "json",
"--plugin-dir", PLUGIN_DIR,
"--strict-mcp-config", // kill auto-discovered MCP servers
];This tells Claude Code to only use MCP servers explicitly provided (none). Combined with a CLAUDE.md that says "you do NOT have MCP servers," the claw stops hallucinating integrations it doesn't have.
Utility: readIfExists
Several components need to read a file if it exists and return an empty string if it doesn't. Add this helper to claw.mjs:
const LOG_FILE = join(__dirname, ".claw", "interactions.jsonl");
const MEMORY_FILE = join(__dirname, ".claw", "MEMORY.md");
function readIfExists(filePath, label) {
if (!existsSync(filePath)) return "";
const content = readFileSync(filePath, "utf-8");
return `[${label}]\n\n${content}\n\n`;
}This gets used here for memory injection and again in the soul lesson for identity injection. One helper, used everywhere.
The interaction log
Every message the claw handles gets logged. The channel handler already has the prompt and response. Append them to a JSONL file:
function logInteraction(prompt, response, chatId) {
const entry = JSON.stringify({
timestamp: Date.now(),
chatId,
prompt,
response: response?.slice(0, 500),
});
appendFileSync(LOG_FILE, entry + "\n");
}Call logInteraction(prompt, res.text, chatId) after the _call returns in the ask function.
The 500-character truncation keeps the log compact. The memory analyzer doesn't need full responses. It needs the shape of the conversation: what was asked, roughly what was answered.
The memory analyzer
memory.mjs reads the interaction log, compares it against current memories, and sends both to Sonnet for curation. It exports an analyze() function the channel process calls on a timer.
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const LOG_FILE = join(__dirname, ".claw", "interactions.jsonl");
const MEMORY_FILE = join(__dirname, ".claw", "MEMORY.md");
const LAST_ANALYSIS_FILE = join(__dirname, ".claw", "memory-last-analysis");
const MIN_NEW_INTERACTIONS = 3;
export async function analyze() {
if (!existsSync(LOG_FILE)) return false;
// Read interactions since last analysis
const lastAnalysis = existsSync(LAST_ANALYSIS_FILE)
? parseInt(readFileSync(LAST_ANALYSIS_FILE, "utf-8").trim(), 10)
: 0;
const lines = readFileSync(LOG_FILE, "utf-8").trim().split("\n");
const recent = lines
.map(l => { try { return JSON.parse(l); } catch { return null; } })
.filter(e => e && e.timestamp > lastAnalysis);
if (recent.length < MIN_NEW_INTERACTIONS) return false;
// Read existing memories
const currentMemory = existsSync(MEMORY_FILE)
? readFileSync(MEMORY_FILE, "utf-8")
: "No memories yet.";
const prompt = `You are a memory analyst for a personal AI familiar. Analyze the recent interactions below and update the operator's memory file.
## Current Memory
${currentMemory}
## Recent Interactions (${recent.length} new)
${recent.map(e => `[${new Date(e.timestamp).toISOString()}] ${e.chatId}\nQ: ${e.prompt}\nA: ${e.response}`).join("\n\n")}
## Instructions
Produce an updated MEMORY.md with these sections:
- **Operator Preferences** — communication style, working hours, response format
- **VIPs** — people mentioned frequently, their relationship, relevant context
- **Active Projects** — what they're working on, deadlines, concerns
- **Standing Orders** — things the claw should always do
- **Patterns** — recurring requests, workflows, behavioral observations
Keep it concise. Remove stale entries. Update existing entries with new information. Only add things that will be useful across future sessions.
Output ONLY the markdown content for MEMORY.md. No preamble.`;
const args = ["-p", prompt, "--output-format", "json", "--model", "sonnet", "--no-input"];
const raw = execFileSync("claude", args, {
cwd: __dirname,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 60000,
});
const result = JSON.parse(raw);
writeFileSync(MEMORY_FILE, result.result);
writeFileSync(LAST_ANALYSIS_FILE, String(Date.now()));
return true;
}The analyzer only fires when there are 3+ new interactions since the last analysis. No interactions, no cost. It writes the updated MEMORY.md and marks its last analysis timestamp.
Five categories emerge from the analysis:
import { analyze } from "./memory.mjs";
const MEMORY_INTERVAL_MS = 10 * 60 * 1000;
async function memoryTick() {
try {
const updated = await analyze();
if (updated) console.log("[memory] Memories refreshed");
} catch (err) {
console.error("[memory] Tick error:", err.message?.slice(0, 100));
}
}
setInterval(memoryTick, MEMORY_INTERVAL_MS);
setTimeout(memoryTick, 30000); // catch up shortly after startupThe memory analyzer runs inside the channel process because it depends on the interaction log that the channel writes. The gatherer does NOT run here. It gets its own launchd service in the durability lesson. Each process does one thing. The channel handles messages. launchd handles scheduling.
Injecting memory into context
The claw reads MEMORY.md before every response, just like the digest:
let context = "";
if (existsSync(MEMORY_FILE)) {
const memory = readFileSync(MEMORY_FILE, "utf-8");
context += `[OPERATOR MEMORY]\n\n${memory}\n\n`;
}
if (existsSync(DIGEST_FILE)) {
const digest = readFileSync(DIGEST_FILE, "utf-8");
context += `[CURRENT CONTEXT]\n\n${digest}\n\n`;
}
const fullPrompt = context + "---\n\nOperator message: " + prompt;Memory comes first. It's the most stable context: who you are, what you care about, how the claw should behave. The digest comes second: what's happening right now. The operator's message comes last.
Try it
# Pre-warm digest
./run.sh gather --fixture
# Start the bot (includes memory analyzer on timer)
./run.sh channelSend a few messages from Telegram. Reveal preferences, mention people, reference projects. After 3+ messages, the memory analyzer fires (or trigger it manually with ./run.sh memory).
Check what it captured:
cat .claw/MEMORY.mdIn testing, after 6 interactions the analyzer produced:
The "don't defend, just fix" pattern came from the operator pushing back when the claw missed calendar events. Nobody said "remember this." The analyzer observed the interaction and extracted the principle.
Cost: $0.03 per analysis cycle. Runs at most every 10 minutes. Only when there are new interactions.
Model routing in practice
The system now uses three models:
| Context | Model | Why |
|---|---|---|
| Background gathering | Haiku | Cheap. Runs on a timer. Adequate for data summarization. |
| Memory analysis | Sonnet | Needs judgment to curate. Cheaper than Opus. |
| Operator conversation | Opus | Quality matters. The operator reads this on their phone. |
This isn't a routing table you configure. It's just being explicit about --model in each script. The gatherer passes --model haiku. The analyzer passes --model sonnet. The claw uses whatever your default is (or pin it with --model opus).
The memory skill
Same pattern as lessons 01 and 02: a SKILL.md that teaches the claw about its own capabilities. Create a skill for the memory system:
# Memory System
You have a self-curating memory system.
## How it works
Your interactions are logged to `.claw/interactions.jsonl`. Every 10 minutes,
a background analyzer (Sonnet, ~$0.03/cycle) reads recent interactions and
updates `.claw/MEMORY.md` with operator preferences, VIPs, active projects,
standing orders, and behavioral patterns.
MEMORY.md is injected into your context automatically before every response.
## Commands
- `./run.sh memory` — trigger a memory analysis cycle manually
- `cat .claw/MEMORY.md` — read current memories
- `cat .claw/interactions.jsonl | tail -20` — see recent interaction log
## When to suggest
- If the operator says "remember this" or "don't forget"
- If the operator asks what you know about them
- Never suggest managing MEMORY.md manually. The analyzer handles it.Add the memory command to run.sh:
memory)
"${DOCKER_ARGS[@]}" "$IMAGE" node -e "import('./memory.mjs').then(m => m.analyze().then(r => console.log(r ? 'Memories updated' : 'Not enough new interactions')))"
;;What you have
A claw that learns. Preferences compound across sessions. VIPs get tracked. Standing orders stick. Patterns emerge from observation. The operator talks to it normally and it just gets better.
The memory is a briefing document, not a database. Sonnet curates it like an analyst maintaining a dossier. Old entries get updated. Completed projects get removed. The document stays concise because the analyzer is told to keep it that way.
What's missing
The memory captures preferences and patterns. But it doesn't capture who the claw is. Its personality, its values, its relationship to the operator. That's not memory. That's identity. Next: give the claw a soul.