Agent state as plain files
Each character in our agent runtime is a folder of markdown and JSON. Hand-editable, diffable, and greppable — which turned out to matter more than any of the clever alternatives.
Our agent runtime stores each character as a folder. Not a row, not a document in a collection — a directory of markdown and JSON on disk.
characters/
archivist/
character.md # who it is, how it behaves
tools.json # what it is allowed to do
memory/
2026-08-14.md # what happened, one file per day
2026-08-15.md
knowledge/
house-style.md
The obvious objection is that this is not how you store application state, and for most application state that is correct. For agent configuration it turned out to be the design decision we would keep.
Agent behaviour is defined by text a human wrote. Storing it as text a human can open is not a limitation — it is the shortest path between wanting a change and having one.
What it buys
It diffs. A character's behaviour changed and you can see exactly how, in
git log. Not an audit table recording that system_prompt was updated at
14:22 — the actual before and after, in a review, with a message explaining why.
This is the biggest single benefit and it is the one that is hardest to
retrofit.
It greps. "Which characters can write files?" is grep -l write_file characters/*/tools.json. In a database it is a query someone has to write, and
in a JSON blob column it is a query someone has to write badly.
It sends. A character is a directory. You can zip it, attach it, commit it to a repository, or hand it to someone who does not have your system. Sharing behaviour becomes sharing files, which needs no export feature.
It edits without a tool. Changing how a character responds is opening a file in whatever editor you already have. No admin interface, no migration, no form. For a system whose entire configuration is prose, the prose editor is the right interface.
What it cost
Not free, and the trade is worth stating.
Concurrent writes need care. Two processes appending to the same memory file will interleave badly. Writing per-day files rather than one long file removes most of the contention, and an append with an exclusive lock handles the rest. A database would have given this for free.
No queries across characters. "Which characters mentioned this topic last week" is a filesystem walk, not an index. Fine at tens of characters, wrong at tens of thousands.
No transactions. Updating tools.json and character.md together is two
writes with a window between them. In practice this matters less than expected
because the runtime reads the folder fresh on each run, so a half-applied change
is caught immediately rather than persisting.
The honest summary: this works because the scale is small and the write pattern is human-paced. It is a design that suits configuration and would be wrong for transactional data.
The rule that made it work
The constraint that turned this from a nice idea into something reliable: the runtime reads the folder, and never writes to the parts a human owns.
character.md human writes, runtime reads — behaviour
tools.json human writes, runtime reads — permissions
memory/ runtime appends, human reads — what happened
knowledge/ human writes, runtime reads — reference material
One direction per file. Mixed-ownership files are where this design fails —
if the runtime rewrites character.md to record something, a human edit and a
runtime write will eventually collide, and worse, the file stops being reviewable
because half of it is machine-generated noise.
Memory is append-only and dated for the same reason. The runtime never edits history; it adds to it. That makes the memory directory a log rather than a state file, and logs are much easier to reason about when two things are writing.
Why permissions live next to behaviour
tools.json sitting beside character.md is deliberate. The
allow-list is part of what a character is, not a
separate deployment concern:
{
"allow": [
{ "tool": "read_file", "paths": ["./knowledge", "./memory"] },
{ "tool": "write_file", "paths": ["./memory"], "approval": "never_required" },
{ "tool": "web_search" }
]
}
Reviewing a change to what a character can do is reviewing a diff on a small JSON file, in the same pull request as the behaviour change that motivated it. When permissions live in a separate system, they get widened during debugging and never narrowed, because nobody is looking at them.
Anything that writes outside ./memory still
stops for approval. The file layout
makes the boundary visible; the runtime enforces it.
Where it generalises
The pattern is not really about characters. It is: when the state is authored by humans and read by machines, store it the way humans author things.
Prompts, evaluation cases, rule packs, routing tables, agent configuration — all of these are text someone wrote, all of them benefit from review and history, and all of them are routinely put in a database because that is where state goes.
The test is which direction the writes flow. Human-authored, machine-read: files, in the repository, reviewed like code. Machine-generated at volume: a database. Mixed: split it, or accept that the file will stop being readable.
This is from one of ours. More in the approval gate is a state machine, allow-lists, not deny-lists, and the case studies.