Get started Dashboard
Engineering · October 22, 2026

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.

Figure 1
How Polylane’s memory works

Agent threads write to a git repository, a nightly memory update rewrites it, and new threads start from its index

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.

memory
daily/
preferences/
slack/
repositories/
acme/
services/
conventions/
.dream/
MEMORY.md
The index. Every thread starts with it, so it stays under 150 lines and links to the detail.
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
daily/2026-10-21.md
One note per day, at most 40 lines. The newest three are in every 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).
preferences/slack/C04ONCALL.md
Standing instructions for one Slack channel. Read into every thread started from that channel.
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.
preferences/repositories/acme/web.md
Instructions for reviewing pull requests in one repository. Read by every review of that repository.
Written by
People
Editable by
People
Flag any migration that locks a table for more than a second.
Ignore formatting: CI runs prettier.
services/checkout-api.md
A topic file: one fact, the resource and the date in every entry, a source at the end.
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]
conventions/acme-web.md
Lessons from how people changed and merged the agents' pull requests in one repository.
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]
.dream/state.json
What the nightly update checked and when, and what it could not verify. Only the nightly update writes it.
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"]
}
Figure 2
One workspace's memory repository
Select a file to see what it holds and who may change it. The workspace and its contents are an example; the layout, owners and limits are the real ones, enforced when changes are pushed.

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.md so 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.

Each claim the nightly update checks is kept, fixed, deleted within the cap, or kept and marked unverified

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.

Merges and closes by a person teach the nightly update; a lesson needs a repeated cause or a stated reason

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.

People and agents write to one workspace memory, and every teammate's next thread reads it

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, writeMemory and deleteMemory, 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.

Nobody should be on-call. Polylane watches your infra, investigates, and fixes what breaks.

Get started for free

Continue reading

Engineering · Oct 14, 2026

Nobody reads pull request descriptions, so we made videos

Almost none of the pull requests Polylane opens were asked for by an engineer, and a longer description does not make them easier to review. We are experimenting with a narrated video for every fix. Why, what it looks like, and what happened to merge rates.

Vi Tran and Boris Tane
Engineering · Oct 5, 2026

Turn your app into a context graph

Agents that run software need a context graph of the app: every resource, what it connects to, the repository that deploys it and the team that owns it. How we built one on Durable Objects, keep it fresh across every provider without polling AWS, decide what counts as a change, and delete from it safely.

Boris Tane