How to build a memory system for proactive agents
Explore with AI
What happens when an agent investigates the same failure twice? It does all the work again. It reads the same logs, tests the same hypotheses, and ultimately, it lands on the same answer. This is not only slow, but also not token efficient, investigation agents need to remember old incidents, resolutions, and team preferences.
At Polylane, our agents keep that memory in a git repository, one per team, hosted on Cloudflare Artifacts. They edit markdown files in it while they work, and every night, an agent does what we call dreaming: it verifies what it’s in the memory against ground truth, deletes what is no longer true and re-organises the repo to facilitate navigating it by other agents.
graph TB T["Agent threads"] -->|"save what they learn"| R["Memory repository"] S["The day's threads and PRs"] --> D["Nightly memory update"] D -->|"notes, fixes, index"| R R -->|"index and daily notes"| B["Context of the next thread"] B --> T style R fill:#d1fae5,stroke:#6ee7b7,color:#065f46
What Polylane needs to remember
Every agent run learns something the next one could use:
- Root causes it already found. If an investigation confirmed that checkout-api times out because the payment vendor’s sandbox can’t handle parallel load, the next one shouldn’t have to find out again.
- Known noise. The alert that fires every night during the batch job and that nobody acts on.
- How the team changes our code. If developers keep rewriting tests in the PRs Polylane opens to use their fixtures before merging, the next pull request should just use the fixtures.
- How the team wants to work. How to behave in a Slack channel, etc.
- What happened recently. The deploys, incidents and patterns of the last few days.
- Tribal knowledge. Things you can’t read from telemetry, like “this alert is expected” or “that service is being retired”.
On top of that, the memory has to be shared by the whole team, and it has to be easy to correct. A fact that was true last month can be wrong today.
Why git
We started with vector embeddings, like most agent memory does. We quickly realised it was not the right abstraction. A wrong fact was hard to find and harder to fix, there was no history of who wrote what, and the agent only ever saw whatever the similarity search decided to return.
Once we had that list, the requirements looked a lot like source code. We wanted history, to see how a fact changed and why. We wanted diffs, so a thread can incrementally get changes. We wanted lots of writers at the same time, as there could be multiple concurent investigations.
That’s git, and agents are already very good at git. Give the agent a shell and it can grep, read files, edit them and look at the log, etc.
It also means we don’t need a search index. A root file MEMORY.md can link to the files in the memory repository, and it’s the entry point for the agent when exploring the memory.
What’s in the repository
Each workspace on Polylane gets one repository with a common structure: a main MEMORY.md file that outlines all other files, daily notes which summarizes the agent activity daily, individual files about specific topics, and preferences files for how people like to interact with Polylane in Slack or on Github for example.
- Written by
- The nightly memory update
- Editable by
- Agents, people and the nightly memory update
# acme memory - [checkout-api](services/checkout-api.md): 504s under parallel load from the payment sandbox - [orders-db](services/orders-db.md): connection limit, slow queries after deploys - [nightly batch alert](alerts/nightly-batch.md): fires every night, nobody acts on it - [acme/web](conventions/acme-web.md): test and naming conventions from merged PRs - [daily notes](daily/): one per day, newest three are in your briefing
- Written by
- The nightly memory update
- Editable by
- People and the nightly memory update
# 2026-10-21 - 14 threads, 9 of them about checkout-api 504s after the 11:40 deploy of acme/web. - On each of 6 triage cycles, the assistant proposed raising the nightly-batch alert threshold. No person acknowledged any of them. - A person merged acme/web#44 after replacing its inline mocks with test/fixtures (tentative: second time this week).
- Written by
- People
- Editable by
- People
Reply in the thread, never in the channel. Lead with the customer impact, then the cause. Page the payments team only for checkout-api.
- Written by
- People
- Editable by
- People
Flag any migration that locks a table for more than a second. Ignore formatting: CI runs prettier.
- Written by
- Agents
- Editable by
- Agents, people and the nightly memory update
checkout-api: integration failures caused by the payment vendor's sandbox - 2026-10-14: checkout-api integration tests fail intermittently because the payment vendor's sandbox returns 504 under parallel load. Confirmed from pipeline logs. [thread_7c2e, 2026-10-14] - 2026-10-21: the same 504s appear in production traffic after deploys that warm the connection pool. [thread_91ab, 2026-10-21]
- Written by
- The nightly memory update
- Editable by
- Agents, people and the nightly memory update
acme/web: conventions learned from merged pull requests - Tests use the shared fixtures in test/fixtures, not inline mocks: people replaced inline mocks before merging twice. [acme/web#41, acme/web#44, 2026-10-21]
- Written by
- The nightly memory update
- Editable by
- The nightly memory update
{
"lastRun": "2026-10-22T03:12:09Z",
"lastCommit": "9f2c41e",
"checked": {
"services/checkout-api.md": "2026-10-22",
"services/orders-db.md": "2026-10-22"
},
"unverified": ["queues/invoice-events.md"]
} Preferences live in the same repository as facts: instructions for how the agents should behave in a Slack channel, or what to look for when they assess the impact of a pull request in a repository. These are just files that agent can read at any point in time. Engineers can edit those files in the web console or via MCP, which builds a real git commit and pushes it, so a change to a channel’s instructions has the same history, diff and revert as any other memory. When a user then starts a thread from a Slack channel, the preferences for this channel are loaded into the prompt dynamically.
Everything else is a topic file, and the agents follow a few simple rules for those: one fact per file, a first line that says what the file is about, and the name of the resource and a date on every entry. Here’s what one looks like after a couple of investigations:
checkout-api: 504s caused by the payment vendor's sandbox
- 2026-10-14: checkout-api integration tests fail intermittently because the
payment vendor's sandbox returns 504 under parallel load. Confirmed from
pipeline logs. [thread_7c2e, 2026-10-14]
- 2026-10-21: the same 504s show up in production traffic after deploys that
warm the connection pool. [thread_91ab, 2026-10-21]
That’s it. No schema, no embeddings. Anything that can run cat can read it, and so can you.
Every thread shares the same memory
Polylane runs a lot of threads at the same time: an investigation into an alert, a review of a pull request, someone asking a question in Slack. They all work on the same repository.
The first time a thread uses its sandbox, we clone the memory repository into it at /workspace/memory. From there the agent uses the same tools it uses on any codebase. It greps for the service it’s looking at, reads what earlier threads found, and edits markdown when it confirms something new.
What it can’t do is push. There are no git credentials in the sandbox, on purpose. An agent that spent the day reading logs and web pages should not be writing straight to a team’s shared memory. So the agent only changes files, and everything that touches the remote happens in code, after its turn ends.
Every night, Polylane dreams
Agents write to memory all day, and left alone that turns into a mess: duplicates, facts that stopped being true, and an index pretty much impossible to read. So once a day, every workspace gets a memory update, we call it dreaming.
Dreaming starts from what happened during the day. After every turn, a small model summarises each thread in two parts: what’s worth remembering for weeks, and what this thread found that will probably be stale soon.
Dreaming consists of fetching the last 24 hours of summaries and the pull requests people merged or closed from the ones Polylane opened. Then it works through the same set of checks and tasks every night:
- Daily note. It writes a short note about the day: only what someone would still find useful a week later.
- Outcomes. It turns how Polylane’s pull requests ended into lessons for that repository.
- Corrections. It checks a handful of files against production and fixes what’s wrong.
- Tidy. It merges files that say the same thing and repairs broken links.
- Index. It rewrites
MEMORY.mdso it stays short and points at everything else. - State. It records what it checked, so tomorrow’s run knows where to start.
Each task is its own commit. If one of them goes wrong, you revert that commit and keep the rest.
Most of that is housekeeping. Two tasks are where the memory actually gets better: checking it against production, and learning from how work ended.
Checking memory against production
A fact that was true last week can be wrong today. The service moved region, someone raised the connection limit, the cron job was deleted. Our memory system needs to accomodate this, otherwise, it slowly fills up with things that used to be true.
So every night, the dreaming process picks files and checks every claim in them against the real system: the cloud APIs, monitoring queries, the code, etc. True claims remain, and wrong ones get fixed, with the evidence in the commit message. If a claim can’t be fixed or verified, it gets deleted.
graph TB C["A claim to check"] -->|"checkable"| R["Checked"] C -->|"not checkable"| U["Keep, mark unverified"] R -->|"true"| K["Keep, record date"] R -->|"wrong"| W["Can it be fixed?"] W -->|"yes"| X["Fix it"] W -->|"no"| D["Delete, within cap"] style X fill:#d1fae5,stroke:#6ee7b7,color:#065f46 style D fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
Which files get checked is decided in code, not by the agent. First the files that changed since the last run, then the ones never checked, then the ones checked the longest ago, so over a few weeks everything gets looked at again. There’s also a cap on how much one night can delete. An agent that decides half the memory is wrong is more likely to be wrong itself.
// Tonight's files: changed since the last run, then never checked, then stalest.
function selectFilesToVerify(files: string[], changed: string[], checked: Record<string, string>, today: string) {
const topic = files.filter(isTopicFile);
const recent = topic.filter((path) => changed.includes(path));
const rest = topic.filter((path) => !recent.includes(path) && checked[path] !== today);
const never = rest.filter((path) => checked[path] === undefined);
const stale = rest.filter((path) => checked[path] !== undefined).sort((a, b) => checked[a].localeCompare(checked[b]));
return [...recent, ...never, ...stale].slice(0, 25);
}
// The most files one night may delete: 15% of the topic files, at least three.
const deletionCap = (topicFiles: number) => Math.max(3, Math.floor(topicFiles * 0.15));
The dreaming thread keeps track of what it checked in .dream/state.json. It’s the only file in the repository that nobody else can write, not even engineers:
{
"lastRun": "2026-10-22T03:12:09Z",
"lastCommit": "9f2c41e",
"checked": {
"services/checkout-api.md": "2026-10-22",
"services/orders-db.md": "2026-10-22"
},
"unverified": ["queues/invoice-events.md"]
}
Every entry the dreaming thread writes also carries its source, like [thread_abc, acme/web#41, 2026-10-17]. This helps Polylane verify the origin of memories when things are ambigous.
Learning from merged pull requests
When someone merges a pull request Polylane opened, what they changed before merging says more than any review comment. Nobody writes “please use our test fixtures”. They just fix it and merge.
So every night, we look at what people did on top of our pull requests: the commits they pushed, their comments and their reviews. A pull request merged untouched means the fix was fine as it was.
graph TB P["An agent's pull request"] --> M["Merged"] P --> C["Closed"] M -->|"untouched"| A["Accepted"] M -->|"edited"| L["Lesson if it repeats"] C -->|"by a person"| L C -->|"by the system"| N["Nothing learned"] style L fill:#d1fae5,stroke:#6ee7b7,color:#065f46 style N fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
The lesson goes into a file for that repository, and the next pull request there starts from it.
Memory is shared and portable
Memory belongs to the workspace. When your teammate’s investigation figures out why checkout-api returns 504s after a deploy, your next question about checkout-api goes to an agent that already knows.
graph TB P["People: thread, Slack, web app"] --> R["Workspace memory"] A["Agents: threads, MCP"] --> R R --> T["Every teammate's next thread"] style R fill:#d1fae5,stroke:#6ee7b7,color:#065f46
And Polylane isn’t the only one writing. People teach it how they work:
- Ask in a thread. “save a note: the nightly batch alert is expected, nobody acts on it”
- Tell it in Slack. Ask it to behave differently in a channel, and it updates that channel’s preferences file.
- Edit the files. Open the memory in the web console and change what you want.
- Let your agent do it. Our MCP server has
readMemory,writeMemoryanddeleteMemory, so your coding agent can read and update the same memory.
Because it’s git, you can always see who taught it what. Agent commits point to the thread they came from, and a person’s edit carries their name. When a fact is wrong, git log tells you where it came from, and a revert takes it out.
Memory and the prompt cache
Every thread starts with MEMORY.md and the last three daily notes in its context. Anything else, the agent greps for.
The catch is that memory changes all day. If every turn put the latest version in the prompt, the start of the prompt would keep changing, and the model provider’s prompt cache would miss on almost every turn. A thread can run for days, and that means paying full price for the same tokens over and over.
So the prompt doesn’t get the latest version. It gets a snapshot, and only the nightly update, or someone editing the index by hand, publishes a new one. Each thread pins the snapshot it started with and keeps it for its whole life. The start of the prompt never changes, and the cache keeps working.
async function getMemorySnapshot(threadId: string) {
const versions = await storage.get<VersionedSnapshot[]>("memoryPromptSnapshots");
const latest = versions.at(-1);
const pin = await storage.get<SnapshotPin>(`memoryPin:${threadId}`);
if (!pin) {
// First turn: pin whatever is newest now.
await storage.put(`memoryPin:${threadId}`, { version: latest.version, pinnedAt: Date.now() });
return latest.snapshot;
}
// Every later turn: the same version, so the prompt prefix never changes.
return (versions.find((entry) => entry.version === pin.version) ?? latest).snapshot;
}
That doesn’t mean the agent works from stale memory. The repository on disk is always current, and the prompt tells the agent how to see what changed since its snapshot: git -C /workspace/memory log --since={time} --name-only. We also tell it to treat every entry as something that was true when it was written, not as a verified fact.
Start with files
If you’re building proactive agents, start with a git repository of markdown files. Agents already know how to use it, and git gives you history and reverts for free. Then spend your effort on keeping it true: check memory against production every night, and keep the agent away from the push.