Here's a test you can run on any "AI-powered outreach" system, including the one a vendor is demoing for you right now. Pick a contact and ask: why did we email this person, and what do we know about them?
If the answer comes back with dates and receipts (researched them on this day, found this, sent this, they replied with that) the system has memory. If the answer is a confidence score and a vibe, it has a database pretending to be memory. Most systems are the second kind, because memory is the subsystem everyone skips. It's invisible in a demo. It pays off in month three.
I think it's the single highest-leverage thing we built. This chapter is the design, the two failures that preceded it, and the honest part where I show you our own audit numbers, which are humbling.
One table, timestamped, append-only in spirit
The core is almost embarrassingly simple. A table called contact_episodes. Every row is one thing that happened, tied to one contact, at one moment:
CREATE TABLE contact_episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
episode_type TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT,
confidence REAL DEFAULT 1.0,
created_at TEXT NOT NULL
);
Seven episode types, and the list is a worldview: research (what we found out about them), company (about their organization), outreach (what we sent them), response (how they responded), engagement (social and content signals), note (things I add by hand), scoring (why their score changed).
Look at what that last one implies. When a contact's warmth changes, the reason gets written down as an episode, same as everything else. The machine doesn't just hold a score. It holds the score's biography. That's the difference between a CRM field and a memory.
The confidence column is the other quiet load-bearer. A fact scraped by a local model is not as trustworthy as a reply the human actually sent us, and the row says so. When memory feeds generation later, provenance is the difference between personalization and fabrication. Chapter 6 is about what happens when you skip that.
Indexes on contact_id and episode_type, and that's the whole design. No graph database, no embeddings required for the core loop. The most valuable subsystem in the machine is a flat table with a timestamp discipline.
How it gets written, and how it gets read
The outreach engine runs daily at 7:55 and is deliberately slow. Up to 20 contacts researched and 10 drafts a day, hard caps, by design. Slow matters here. Each researched contact gets episodes written for findings and company intel. Each draft sent becomes an outreach episode plus a row in an outreach table that tracks subject, body, the angle we took, sent/opened/replied timestamps, and bounce status. Replies come back through inbox triage and land as response episodes that also bump warmth. Follow-up bumps are tracked per thread (we added explicit bump counting this month, with a politely-twice-and-done rule), so "did we already nudge this person" is a query, not a guess.
The read side is where the table earns its keep. Before drafting, the engine pulls the contact's last 30 episodes, flips them chronological, and builds a narrative. A dossier, assembled fresh from receipts at the moment of writing. No prior history returns the honest answer: "No prior research or interaction history." And I get the same view from a terminal. One command, one email address, the full timeline of everything the machine knows about a person and every word it's exchanged with them.
That command is the audit trail. When the machine emails someone, I can reconstruct exactly why, from rows with dates. Keep that in mind for the alternatives section, because it's the property every alternative gives up first.
One unglamorous guardrail worth copying. When research wants to improve the CRM record itself, fill in a city, a title, a company, the writeback goes through a hardcoded whitelist of allowed columns. The model proposes; the whitelist disposes. A local model with open-ended write access to your contact database is a bad day waiting for a cron slot.
What failed first
Two designs died before this one, and they're the two everybody tries.
Failure one: stuff everything into the prompt. Dump the contact's whole CRM record, prior emails, and notes into context and let the model sort it out. Works for the first demo. Then histories grow, the context fills with mostly-irrelevant text, the model starts attending to the wrong details, and costs and latency scale with history length. Worse, there's no provenance. The model sees a soup of text with no marker for what's verified versus what some earlier model guessed. The soup is how guesses get laundered into facts.
Failure two: rolling summaries. Keep a per-contact summary, update it after each event. Compact, cheap, and a one-way information shredder. Each rewrite is a chance for the summary to drift from the evidence. A date dropped here, a hedge dropped there. After ten rewrites you have a confident paragraph nobody can trace to anything. The receipts are gone, and "why did we email this person" is back to being unanswerable.
The episodic design keeps raw events forever and synthesizes fresh at read time. Synthesis is cheap and disposable. Evidence is permanent. If you remember one sentence from this chapter: store events, derive summaries, never the reverse.
The nightly synthesis, and where vectors actually fit
Contact memory is one scope. The machine also remembers itself, and that runs on the same store-events-derive-summaries principle, nightly.
At 11:30pm, a synthesizer reads the last 24 hours of operational activity: scheduler runs, outreach activity, engagement numbers, blog and social output. It scores each observation for importance and writes it into a brain database of facts, decisions, and a knowledge graph. At 11:45, an export mirrors that to plain markdown files, because a memory only a program can read is a memory with one point of failure and zero auditors. I can open last Tuesday as a text file.
Vector memory sits beside all this, not under it. A local ChromaDB store holds embeddings, generated locally so nothing leaves the box, in two collections: knowledge and episodes. Vectors answer the question SQL can't, which is "have we seen something like this before?" Fuzzy recall across thousands of entries. What vectors don't do is serve as the system of record, because similarity is not provenance.
And vector stores rot. Quietly. Left alone, everything becomes dimly similar to everything and recall quality sinks. So a nightly aging pass scores memories one-to-five on importance, ages them by tier, moves the losers to an archive collection, deletes archived entries past a 180-day TTL, and enforces hard size caps on both collections. Forgetting is a feature you have to build. Nobody puts that on the architecture diagram, and it's half of why recall still works.
The humbling part
Now the audit, because this track promised receipts even when they're unflattering.
Our CRM holds 156,925 contacts. When we audited it this month: 99.8% cold. About 280 contacts warm or hot. Roughly 18,000 records clean enough to act on, 8.1% with an email address at all. And the metric that stung, across the whole CRM, the engagement-events table held nine rows. Nine. The episodic machinery worked beautifully for the contacts the outreach engine touched, and meanwhile the other 150-odd-thousand records were a years-deep contact dump generating no signal, because nothing was wired to capture any.
The lesson I'd hand you: memory infrastructure without signal capture is a filing cabinet in an empty office. The table is necessary and nowhere near sufficient. Every channel a customer can touch, your site, your inbox, your social, your phone, either writes an episode somewhere or that touch never happened, as far as the machine knows. We built the cabinet first and the wiring second. Build them together.
The alternatives, fairly
RAG-only. Embed your documents, retrieve at generation time. Genuinely the right call for knowledge: your service docs, your pricing. But RAG over documents gives you recall without history. It can tell you what your refund policy says. It cannot tell you that this customer asked about refunds twice in March and got no reply. Use RAG for what you know. It does not cover who you know.
Fine-tuning. Train customer knowledge into the weights. Wrong tool, and it's worth being plain about why. It's expensive. It's stale the day after training. You can't delete one customer from a weight matrix, which becomes a real problem the first time someone invokes a privacy right. And there's no audit trail at all, because the knowledge is smeared across parameters. Fine-tuning teaches a model how to behave. Facts about customers belong in a database the model reads, not in the model.
Framework memory modules. LangChain and friends ship memory classes: conversation buffers, entity memory, vector-store wrappers. Fine for getting a prototype to remember the conversation it's currently in. The trouble starts at audit time. The storage schema is the framework's, the summarization policy is the framework's, and when you need to know why the AI claimed something about a customer, you're reverse-engineering an abstraction instead of querying your own table. Memory is precisely the layer where I'd tell even framework-friendly engineers: own the schema. It's one table. You just read the whole thing.
The test
Pick a real customer. Ask your system, or your vendor's demo: why did we last contact this person, what did we know when we did, and what happened next? The answer should have dates on every line, and you should be able to see the rows it came from.
Mine passes for the contacts the machine has touched, with a one-line command. It fails honestly for the rest of the database, and the audit told me so in numbers, which is itself the system working.