How to Give Claude Persistent Context in Your Drupal Workflow
Authored on
Have you ever noticed how Claude answers the same question differently depending on the day? Not because it's inconsistent — but because it genuinely doesn't remember you. Every API call starts from zero. No context, no history, no idea what you were working on yesterday.
I ran into this hard when I built a Claude-powered agent to assist with my own Drupal development workflow — code reviews, deployment checks, project status, the occasional database query. The thing worked great in a single session. The moment I came back the next morning, it was like meeting a stranger who happened to know Drupal.
So I set out to fix that. Here's what I tried, what broke, and what actually works.
First attempt: timestamped summaries
The idea seemed solid. After every exchange with the agent, fire a second Claude call and ask it to write a 3–5 sentence summary of what just happened. Save that to a timestamped markdown file. Next session, load the last N days of files and inject them at the top of every prompt. Continuity, zero manual effort. Done.
It worked — kind of. The agent did remember things across sessions. But after a couple of weeks I started noticing the cracks.
The summaries were an unstructured blob. Everything got the same weight — a quick "what's the drush command to clear cache" had the same footprint as a thirty-minute architecture discussion. There was no signal about what actually mattered. Claude knew conversation history but had no idea what projects were active, what was blocked, or what I'd shipped last week.
The files also grew indefinitely. No natural cleanup boundary. And the recap heuristic I added — trigger a summary if the gap between messages was more than 12 hours — felt fragile the moment I started working across time zones.
The real problem: storing everything equally means nothing stands out.
Designing the Memory Bank
The shift that fixed it was obvious in hindsight. Memory shouldn't be organized by time — it should be organized by type.
I replaced the blob with five focused files:
activeContext.md— what I'm focused on right now, recent decisions, open questionsprojectContext.md— active Drupal projects, their status, blockers, open MRstechContext.md— stack decisions, architecture patterns, known constraints per projectpreferences.md— how I like to work, communication preferences, recurring patternsprogress.md— what's been built, what's still pending
Each file has one job. When the agent needs to know what project I'm on, it reads projectContext.md. When it needs to know I don't want UPDATE queries running on prod without confirmation, that lives in techContext.md — not buried in a timestamped blob from three weeks ago.
Injection order also matters more than I expected. The files closest to the actual message in the token window carry more weight. So activeContext.md always loads last, right before the user's message. The rest load in order of how often they change.
The load function looks roughly like this:
async function loadMemoryBank(memoryPath) {
const files = [
'preferences.md',
'techContext.md',
'progress.md',
'projectContext.md',
'activeContext.md', // loads last — closest to the message
];
const blocks = [];
for (const file of files) {
const filePath = path.join(memoryPath, file);
try {
const content = await fs.readFile(filePath, 'utf8');
blocks.push(`### ${file}\n${content.trim()}`);
} catch {
// file doesn't exist yet, skip it
}
}
return blocks.join('\n\n');
}That output gets injected into the system prompt on every call. No vector DB, no embeddings — just structured markdown read from disk.
The smart update pattern
Reading memory is easy. Knowing when and how to update it is the part that takes some thought.
I ended up with a single Claude call after each exchange. It receives the conversation and a snapshot of all five files, and returns a JSON object with one key per file. The rule is simple: null means leave it alone, anything else means rewrite the file with that value.
async function updateMemoryBank(memoryPath, conversationHistory, currentMemory) {
const response = await callClaude({
system: `You manage a structured memory bank with five files.
Return a JSON object with these keys: activeContext, projectContext, techContext, preferences, progress.
Set a key to null to leave that file unchanged.
Always update activeContext. Only update others if this exchange warrants it.
Return ONLY the JSON object — no explanation, no markdown fences.`,
messages: [
{
role: 'user',
content: `Current memory:\n${currentMemory}\n\nConversation:\n${conversationHistory}`
}
]
});
const updates = JSON.parse(response);
for (const [key, value] of Object.entries(updates)) {
if (value !== null) {
const filePath = path.join(memoryPath, `${key}.md`);
await fs.writeFile(filePath, value, 'utf8');
}
}
}activeContext.md gets rewritten after every exchange. preferences.md only changes when I correct the agent on something. progress.md only when something actually ships. The memory stays lean because not everything warrants an update.
Implementation details worth knowing
A few things I learned the hard way that aren't obvious from the happy path.
Non-blocking updates with setImmediate
The user should get the agent's response immediately. The memory update is a background concern. Wrapping the update call in setImmediate means the response goes out first, and the write happens after the current event loop tick:
// Send response to user first
await sendResponse(userId, agentReply);
// Update memory in the background
setImmediate(() => {
updateMemoryBank(memoryPath, conversationHistory, currentMemory)
.catch(err => console.error('Memory update failed:', err));
});Simple, but easy to skip — and the difference in perceived responsiveness is noticeable.
cwd matters for headless Claude Code calls
This one cost me a missed --backup flag on a production deploy before I caught it. When you invoke Claude Code in headless mode with -p, it silently skips your .claude/rules file if you don't pass an explicit working directory.
// This silently ignores your .claude/rules
claude -p "run the deploy script"
// This respects them
claude -p "run the deploy script" --cwd /path/to/your/projectAlways pass --cwd explicitly on headless calls. Don't rely on the inherited working directory.
Seeding from history
Starting cold means the agent has no useful context for weeks. I bootstrapped the memory bank by feeding months of session logs through a one-time Claude call that extracted the five files from scratch. It took about twenty minutes to review and clean up the output — way faster than watching the agent slowly learn over time.
That said, projectContext.md is the one file Claude can't fully infer on its own. Current project status, active branches, who's blocked on what — that requires a human seed. Design for it upfront: make it easy to update manually, and don't expect the agent to figure it out from conversation history alone.
The .gitkeep pattern
Version the folder structure, gitignore the contents. Runtime memory files have no business in your repository.
# .gitignore
memory/activeContext.md
memory/projectContext.md
memory/techContext.md
memory/preferences.md
memory/progress.mdThen commit a .gitkeep inside the memory/ folder so the directory exists when someone clones the repo. The agent creates the files on first run.
What this actually enables
After a few weeks with the memory bank in place, the difference is hard to miss. The agent knows which Drupal projects are active, what the current blockers are, what tech decisions have already been made. I don't re-explain my stack on every session. It knows I prefer Drush over the UI for configuration imports. It knows which site has a fragile media migration that needs babysitting.
For Drupal work specifically, a few patterns have been useful:
- Per-client memory: each client project gets its own context file rather than one shared blob. No cross-project bleed.
- Team-aware responses: different memory scope per team member. A junior dev asking about deployments gets a different level of detail than a lead asking the same question.
- Site-specific constraints: "never run UPDATE directly on the prod DB" lives in
techContext.mdonce and applies to every session, forever. You stop repeating yourself.
The memory stays lean because the update logic is conservative. Most exchanges only touch activeContext.md. The other files update when something meaningful changes — not just because a conversation happened.
The broader point
Persistent memory for AI agents isn't magic. It's information architecture applied to prompts. The five-file structure isn't novel — it's just a sensible way to organize what the agent needs to know, in a format it can actually use.
The whole pattern is simple enough to implement in an afternoon. The payoff compounds over weeks of real use, once the agent actually knows your projects, your preferences, and your constraints without being told every single time.
I'm still expanding what the agent can do, but the memory layer has stayed stable. That feels like a good sign.
I hope it helps you at some point somehow. Happy coding!