Give a model no memory API, just filesystem tools like mkdir, cat andgrep, and it will naturally use them to store memories. That is what "keep
notes" looks like in the billions of shell transcripts it trained on. This post builds that filesystem in SurrealDB: one
table, five bash-shaped tools, computed paths, and full-text plus vector search
over the same field.
Ask an agent framework what "memory" means and you'll get a different answer
every time: a vector store, a summarization pass, a key-value blob the
framework manages for you and won't show you. Every serious coding agent
already works the other way. It writes a scratch file, greps for something it
wrote three steps ago, edits a todo list in place. Nobody trained it to do
that specifically for memory.
Putting that filesystem in SurrealDB instead of on disk makes the same data
full-text searchable, semantically searchable, and shareable across processes
and users, without leaving SurrealQL.
If you want this off the shelf, it already exists:
surrealfs is a virtual filesystem for
AI agents built on SurrealDB, with the rough edges already handled. This post
builds a smaller version from scratch anyway. The useful part is
seeing why the pattern works and the SurrealQL that makes it short: computed
paths, one field carrying two indexes, and per-user isolation in aPERMISSIONS clause.
What is "agent memory" actually for?
A conversation ends and the context window empties with it. Memory is
whatever survives that: the user's preferences, the state of a project, a
decision made two sessions ago and never revisited. Concretely, it is always
one of three shapes:
notes: free text an agent re-reads to remind itself what's going on
(/notes.md,/projects/<name>/todo.md).facts: small structured statements about the user (
/preferences/editor.md).an index into both: so "what did we decide about the release" finds
the right note without the agent re-reading everything.
A folder of Markdown files covers the first two natively. The third stops being
a separate system once that folder lives in a database that can search its own
contents, as this post shows.
Step 1: start a server
surreal start --user root --pass root memorymemory is SurrealDB's in-memory storage: everything is gone on restart.
Fine for following along, useless for an agent that's meant to remember
things. To keep notes across runs, point the server at a file on disk
instead:
surreal start --user root --pass root rocksdb://agent.dbThat creates (or reopens) agent.db in the current directory and every query
below works unchanged.
Step 2: one table, self-referential
A file tree is a table where every row can point at another row of the same
table as its parent. path is never stored, it's computed from the
parent chain, so renaming or moving a folder is one UPDATE and every
descendant's path follows for free.
Let's create the schema file schema.surql:
DEFINE TABLE OVERWRITE file SCHEMAFULL;
DEFINE FIELD OVERWRITE name ON file TYPE string;
DEFINE FIELD OVERWRITE parent ON file TYPE option<record<file>> DEFAULT NONE;
-- A composite UNIQUE index on (parent, name) would let every root-level file
-- through unchecked: SurrealDB skips a unique index when one of its fields is
-- NONE. `parent_key` mirrors `parent` with the root spelled 'root' so the
-- index has something to compare. It cannot be COMPUTED -- SurrealDB refuses
-- to index a computed field -- so it is a stored VALUE, re-evaluated on write.
DEFINE FIELD OVERWRITE parent_key ON file TYPE string
VALUE IF $this.parent IS NONE { 'root' } ELSE { <string>$this.parent };
-- <string> guards against $this.name being NONE mid-delete.
DEFINE FIELD OVERWRITE path ON file COMPUTED (
($this.parent.path || '') + '/' + <string>$this.name
) TYPE string;
-- A folder is a row with no content and no embedding -- nothing else marks it.
DEFINE FIELD OVERWRITE is_folder ON file COMPUTED (
$this.content IS NONE
);
DEFINE FIELD OVERWRITE content ON file TYPE option<string> DEFAULT NONE;
DEFINE FIELD OVERWRITE embedding ON file TYPE option<array<float>> DEFAULT NONE;
DEFINE FIELD OVERWRITE updated_at ON file TYPE datetime VALUE time::now();
-- Serves both the uniqueness constraint and "everything under this parent" --
-- the hottest lookup here (ls, path resolution). The reverse field order
-- would force a table scan for that query.
DEFINE INDEX OVERWRITE child_unique ON TABLE file FIELDS parent_key, name UNIQUE;
-- Full-text search over file contents. HIGHLIGHTS is what makes
-- search::highlight/search::offsets return real snippets instead of the
-- untouched content -- it stores term positions, so it's opt-in.
DEFINE ANALYZER OVERWRITE file_analyzer
TOKENIZERS blank, class, camel, punct
FILTERS snowball(english);
DEFINE INDEX OVERWRITE file_content_fts ON file
FIELDS content
FULLTEXT ANALYZER file_analyzer BM25(1.2, 0.75) HIGHLIGHTS;
-- Semantic search over the same field. Real embedders return 1536+ dimensions
-- (e.g. OpenAI's text-embedding-3-small); this tutorial uses toy 4-dimensional
-- vectors so the numbers stay readable -- the index and the query are identical
-- either way, just change DIMENSION to match your embedder.
DEFINE INDEX OVERWRITE file_embedding_hnsw ON file
FIELDS embedding
HNSW DIMENSION 4 DIST COSINE TYPE F32;Four choices in there are worth calling out:
is_folderis computed, not stored. A folder is defined as "has no
content", one less field to keep in sync.parent_keyexists only because of an index quirk. AUNIQUEindex
skips any row where one of its fields isNONE, so indexing(parent, name)directly would let two root-level/notes.mdboth get created.
parent_keystores the same thing as a string, with the root spelled
'root'instead ofNONE, so the index has something to compare on every row.pathcannot be indexed. SurrealDB refuses to index aCOMPUTED
field, soWHERE path = $pis a table scan, fine at the size an agent's
notes actually reach (dozens to low thousands of files), and simpler to
read than the alternative. A production version resolves a path by walking
its segments through the(parent_key, name)index instead, as
surrealfs
does in its_resolve().One field, two search engines.
contentcarries aFULLTEXTindex for
keyword search andembeddingcarries anHNSWindex for vector search.
Nothing about the schema treats these as separate subsystems.
Now let's load the schema:
surreal sql --endpoint http://localhost:8000 \
--user root --pass root --ns agent --db memory \
--multi < schema.surqlStep 3: seed a small tree
There is no mkdir here, and no directory type. Every row below is a plainCREATE file, folders included. Two things turn six flat rows into a tree:parent points a row at another row, and leaving content unset is what
makes a row a folder.
LET $notes = (CREATE ONLY file SET name = 'notes.md', content =
"# Working notes\n- Building an internal tool to track deploys.\n" +
"- User prefers terse commit messages, no emoji.");
LET $preferences = (CREATE ONLY file SET name = 'preferences');
LET $editor = (CREATE ONLY file SET name = 'editor.md', parent = $preferences.id, content =
"# Editor preferences\nUses Neovim with a 2-space indent for everything. " +
"Reviews diffs before every commit, never squashes without asking.");
LET $projects = (CREATE ONLY file SET name = 'projects');
LET $launch = (CREATE ONLY file SET name = 'launch', parent = $projects.id);
LET $todo = (CREATE ONLY file SET name = 'todo.md', parent = $launch.id, content =
"# Launch checklist\n- [x] Write the changelog\n- [ ] Cut the release branch\n" +
"- [ ] Announce in #eng-updates");
-- Toy embeddings standing in for a real embedder -- see the note in schema.surql.
UPDATE $editor.id SET embedding = [0.9, 0.1, 0.0, 0.0];
UPDATE $todo.id SET embedding = [0.0, 0.1, 0.9, 0.2];
UPDATE $notes.id SET embedding = [0.6, 0.3, 0.3, 0.1];No statement above wrote a path. Ask for one and the COMPUTED field walks
the parent chain at read time:
SELECT path, is_folder FROM file ORDER BY path;[
{ path: '/notes.md', is_folder: false },
{ path: '/preferences', is_folder: true },
{ path: '/preferences/editor.md', is_folder: false },
{ path: '/projects', is_folder: true },
{ path: '/projects/launch', is_folder: true },
{ path: '/projects/launch/todo.md', is_folder: false }
]Which is this tree, drawn the way an agent expects to see it:
/
├── notes.md
├── preferences/
│ └── editor.md
└── projects/
└── launch/
└── todo.mdStep 4: bash, in SurrealQL
Every tool a filesystem-shaped agent needs maps to one statement. Run these
against the seeded tree (queries.surql has all of them):
ls /
SELECT path, is_folder FROM file WHERE parent_key = 'root' ORDER BY path;[
{ path: '/notes.md', is_folder: false },
{ path: '/preferences', is_folder: true },
{ path: '/projects', is_folder: true }
]cat /notes.md
SELECT content FROM file WHERE path = '/notes.md';[{ content: '# Working notes\n- Building an internal tool to track deploys.\n- User prefers terse commit messages, no emoji.' }]grep -r commit full-text search, ranked:
SELECT path, search::score(1) AS score FROM file
WHERE content @1@ 'commit' ORDER BY score DESC;[
{ path: '/notes.md', score: 0.4417339265346527 },
{ path: '/preferences/editor.md', score: 0.4106069505214691 }
]Real grep shows you the matching line, not just the filename. That'ssearch::highlight, and it needs the HIGHLIGHTS clause on the index,
without it the function silently returns the untouched content (andsearch::offsets returns NONE). The clause is why file_content_fts above
ends in BM25(1.2, 0.75) HIGHLIGHTS; it stores the term positions the
highlighter needs, so it costs index space you only pay for if you want
snippets.
SELECT path, search::score(1) AS score, search::highlight('**', '**', 1) AS snippet
FROM file WHERE content @1@ 'commit' ORDER BY score DESC;[
{ path: '/notes.md', score: 0.4417339265346527,
snippet: '# Working notes\n- Building an internal tool to track deploys.\n- User prefers terse **commit** messages, no emoji.' },
{ path: '/preferences/editor.md', score: 0.4106069505214691,
snippet: '# Editor preferences\nUses Neovim with a 2-space indent for everything. Reviews diffs before every **commit**, never squashes without asking.' }
]Here we surrounded the highlight with ** because the content is in markdown
format, and ** makes it bold.
search::offsets(1) gives the same information as { s, e } character
ranges instead of inline markers, if your agent wants to build its own
snippet window.
mv /projects/launch /projects/v2-launch, the payoff for a computed path:
LET $launch = (SELECT VALUE id FROM ONLY file WHERE path = '/projects/launch');
UPDATE $launch SET name = 'v2-launch';
SELECT path FROM file WHERE parent_key = <string>$launch OR id = $launch;[
{ path: '/projects/v2-launch' },
{ path: '/projects/v2-launch/todo.md' }
]One UPDATE on the folder, and todo.md (never touched) now resolves to/projects/v2-launch/todo.md. A real filesystem needs a recursive rename to
get this; here it's a side effect of path never having been stored.
Edit a file in place, the way you'd sed -i one:
LET $todo = (SELECT VALUE id FROM ONLY file WHERE path = '/projects/v2-launch/todo.md');
UPDATE $todo SET content = string::replace(
content, '[ ] Cut the release branch', '[x] Cut the release branch'
);(sed -i is a common Unix command to edit files in place)
Semantic search, same field, the other index, rank by meaning instead
of shared words:
SELECT path, vector::distance::knn() AS distance FROM file
WHERE embedding <|3, 40|> [0.1, 0.1, 0.8, 0.3]
ORDER BY distance ASC;[
{ path: '/projects/v2-launch/todo.md', distance: 0.0163 },
{ path: '/notes.md', distance: 0.4395 },
{ path: '/preferences/editor.md', distance: 0.8725 }
]The query vector above stands in for an embedded question like "what's left
before we ship?", it never shares a word with the todo list, and still ranks
it first, well ahead of a note about editor preferences.
Step 5: wiring it to an agent
An LLM tool-calling loop needs each of the above as a named function that
takes arguments and returns text. fs_tools.py (see Example files below) is that, in
about 130 lines, ls, cat, write_file, edit, search, each one
SurrealQL statement wrapped in a Python function:
async def search(db: AsyncSurreal, query: str) -> str:
"""Full-text search over every file's content, best match first."""
rows = await _q(
db,
"SELECT path, search::score(1) AS score, "
"search::highlight('**', '**', 1) AS snippet FROM file "
"WHERE content @1@ $query ORDER BY score DESC",
{"query": query},
)
if not rows:
return "(no matches)"
return "\n".join(
f"{r['path']}: {next((l for l in r['snippet'].splitlines() if '**' in l), r['snippet'])}"
for r in rows
)Run it: uv run --with surrealdb python fs_tools.py, and it exercises a
short session against a real server:
>>> write_file('/preferences/editor.md', ...)
Wrote 50 bytes to /preferences/editor.md
>>> ls('/')
87 notes.md
- preferences/
>>> cat('/preferences/editor.md')
Uses Neovim. Reviews every diff before committing.
>>> edit('/preferences/editor.md', 'Uses Neovim', 'Uses Neovim with a 2-space indent')
--- a/preferences/editor.md
+++ b/preferences/editor.md
@@ -1 +1 @@
-Uses Neovim. Reviews every diff before committing.
+Uses Neovim with a 2-space indent. Reviews every diff before committing.
>>> search('changelog')
/notes.md: # Working notesHanding these to a model is deliberately straightforward: a dict of {name:
function}. There is nothing SurrealDB-specific about the wiring, that's the
point of building memory this way. The system prompt that makes an agent
actually use it is one paragraph:
You have a persistent filesystem; treat it as your memory. Record what you
learn about the user's preferences under /preferences/. Give each project a
folder under /projects/<name>/. Search before you create — the file you want
may already exist.That's surrealfs's actual
instructions block, close to verbatim. What it adds over the code above:
binary files, glob, three ready-made integrations
(a plain async API, a pydantic-ai toolset, raw
Anthropic/OpenAI tool schemas), and error handling that turns a wrong path
into a retry instead of a crash. Itsexamples/anthropic_loop.py
is the same idea as fs_tools.py plumbed into a real tool-use loop, in about
40 lines.
Step 6: why put this in SurrealDB, and not a real disk
Nothing above needed a database instead of a folder, ls, cat and edit
work identically over open() and os.listdir(). Three things change once
the "filesystem" is a table:
Search comes for free, and it's already fused. A real filesystem gives
you grep. It does not give you "rank these results by both shared keywords
and similar meaning," which is what makes an agent's memory usable once it
holds more than a handful of files. Here that's reciprocal-rank fusion over
two indexes on one field, no separate vector database to keep in sync, no
second write path. (surrealfs.search() does exactly this fusion; see fs.py
in the repo linked above.)
RBAC is a PERMISSIONS clause, not an authorization layer you build. Add
an owner field and one clause, and every read and write is scoped for free:
DEFINE FIELD OVERWRITE owner ON TABLE file TYPE option<record<user>> DEFAULT $auth.id;
DEFINE TABLE OVERWRITE file SCHEMAFULL
PERMISSIONS FOR select, create, update, delete WHERE owner = $auth.id;Under record access,$auth.id is the signed-in user, so this one clause is the entire
multi-tenancy story: agent A's memory is invisible to agent B's queries, not
because your application code checks a user_id column, but because the
database refuses the row. surrealfs ships this as an optionaluser.surql
you apply only if you need it.
The memory is a database, not a directory on one machine. A file written
mid-conversation by an agent running on one host is immediately queryable from
another (the web UI in Step 7, a nightly job, a teammate's agent) because
it was never local to begin with. "Where does the agent's memory live" stops
being a filesystem-path question and becomes a connection string.
Step 7: seeing it for real
Two examples ship in surrealfs:
The examples uses just to simplify the
commands. If you don't have it, you can run the commands in the
Justfile
directly.
examples/chat_agent.py: the pydantic-ai agent whose instructions you
read in Step 5, as a full web chat (just agent,:7932). It writes to its
own memory mid-conversation and rereads it in the next one.surrealfs/browser: a tree view of the samefiletable on the left, the
selected file open and editable on the right, and a chat panel wired to the
same agent on the far side (just browser,:7933). It's the fastest way
to see what "memory as a filesystem" means: watch a folder appear under
/projects/while you talk to the agent, open it, edit a line by hand, ask
the agent about it in the same breath.
Both need only just db running underneath and the schema applied, no
separate memory subsystem to stand up.
What to consider before production
Path lookups are a table scan. Fine at hundreds of files; past a few
thousand, resolve paths by walking segments through the(parent_key, name)index instead of scanning the computed field,surrealfs._resolve()
is the reference.This schema has no owner. Every file is visible to every session. Layer
on Step 6'sownerfield andPERMISSIONSclause before more than one
person's agent touches the same database.Deletes here are hard deletes. If you need an undo, that's a
deleted_atfield with matching read filters, not a place this schema
currently has an opinion.
Get started
surrealfs on GitHub: the production version of everything in this post
Example files
fs_tools.py
"""The smallest version of agent memory: bash-shaped tools over one SurrealDB table.
An LLM has seen millions of `ls`, `cat`, `mkdir -p` and `grep -r` sessions, so a
tool named `ls` that returns text needs no explanation in a system prompt --
the shape is already in the weights. This module is that idea with nothing
else in it: five functions, each one SurrealQL statement against the `file`
table from schema.surql, each returning plain text a model can read.
Wiring these into an actual tool-calling loop is small -- hand a dict of
{name: function} to whatever provider SDK you use, one branch per tool call.
`surrealfs` (github.com/surrealdb/surrealfs) is the version of this with
error recovery, glob, binary files, and three ready-made integrations
(pydantic-ai, raw Anthropic/OpenAI schemas, a plain async API); its
`examples/anthropic_loop.py` is ~40 lines end to end.
surreal start --user root --pass root memory # in one terminal
uv run --with surrealdb python fs_tools.py # runs the self-check below
"""
from __future__ import annotations
import asyncio
import difflib
from pathlib import Path, PurePosixPath
from typing import Any
from surrealdb import AsyncSurreal
SCHEMA = (Path(__file__).parent / "schema.surql").read_text()
async def _q(db: AsyncSurreal, sql: str, vars: dict[str, Any] | None = None) -> Any:
"""Run one or more statements, return the last one's result."""
return await db.query(sql, vars or {})
async def _resolve(db: AsyncSurreal, path: str) -> dict[str, Any] | None:
if path == "/":
return None
row = await _q(db, "SELECT * FROM ONLY file WHERE path = $path LIMIT 1", {"path": path})
return row
async def ls(db: AsyncSurreal, path: str = "/") -> str:
"""List a folder: one line per entry, `-` size for folders."""
if path != "/":
folder = await _resolve(db, path)
if folder is None or not folder["is_folder"]:
return f"Error: not a directory: {path}"
key = str(folder["id"])
else:
key = "root"
rows = await _q(
db,
"SELECT name, is_folder, "
"IF content IS NOT NONE THEN string::len(content) ELSE 0 END AS size "
"FROM file WHERE parent_key = $key ORDER BY name",
{"key": key},
)
if not rows:
return "(empty)"
return "\n".join(
f"{'-' if r['is_folder'] else r['size']:>6} {r['name']}{'/' if r['is_folder'] else ''}"
for r in rows
)
async def cat(db: AsyncSurreal, path: str) -> str:
"""Read a file's full content."""
entry = await _resolve(db, path)
if entry is None:
return f"Error: no such file: {path}"
if entry["is_folder"]:
return f"Error: is a directory: {path}"
return entry["content"]
async def write_file(db: AsyncSurreal, path: str, content: str) -> str:
"""Create or replace a file, creating missing parent folders."""
parts = PurePosixPath(path).parts[1:] # drop the leading "/"
if not parts:
return "Error: cannot write to the root directory"
parent_id = None
for segment in parts[:-1]:
existing = await _q(
db,
"SELECT * FROM ONLY file WHERE parent_key = $key AND name = $name LIMIT 1",
{"key": "root" if parent_id is None else str(parent_id), "name": segment},
)
if existing is None:
existing = await _q(
db,
"CREATE ONLY file SET name = $name, parent = $parent",
{"name": segment, "parent": parent_id},
)
elif not existing["is_folder"]:
return f"Error: not a directory: {existing['path']}"
parent_id = existing["id"]
name = parts[-1]
existing = await _resolve(db, path)
if existing is not None:
if existing["is_folder"]:
return f"Error: is a directory: {path}"
await _q(db, "UPDATE $id SET content = $content", {"id": existing["id"], "content": content})
return f"Wrote {len(content)} bytes to {path}"
await _q(
db,
"CREATE file SET name = $name, parent = $parent, content = $content",
{"name": name, "parent": parent_id, "content": content},
)
return f"Wrote {len(content)} bytes to {path}"
async def edit(db: AsyncSurreal, path: str, old: str, new: str) -> str:
"""Replace the first occurrence of `old` with `new`, returning a diff."""
entry = await _resolve(db, path)
if entry is None:
return f"Error: no such file: {path}"
current = entry["content"] or ""
if old not in current:
return f"Error: text not found in {path}: {old!r}"
updated = current.replace(old, new, 1)
await _q(db, "UPDATE $id SET content = $content", {"id": entry["id"], "content": updated})
diff = difflib.unified_diff(
current.splitlines(), updated.splitlines(), fromfile=f"a{path}", tofile=f"b{path}", lineterm=""
)
return "\n".join(diff)
async def search(db: AsyncSurreal, query: str) -> str:
"""Full-text search over every file's content, best match first."""
rows = await _q(
db,
"SELECT path, search::score(1) AS score, "
"search::highlight('**', '**', 1) AS snippet FROM file "
"WHERE content @1@ $query ORDER BY score DESC",
{"query": query},
)
if not rows:
return "(no matches)"
return "\n".join(
f"{r['path']}: {next((l for l in r['snippet'].splitlines() if '**' in l), r['snippet'])}"
for r in rows
)
async def demo() -> None:
"""Self-check: a short bash-shaped session against a scratch namespace."""
db = AsyncSurreal("ws://127.0.0.1:8010/rpc")
await db.signin({"username": "root", "password": "root"})
await db.use("tutorial", "fs_tools_demo")
for statement in SCHEMA.split(";\n\n"):
if statement.strip():
await db.query(statement)
assert "Wrote" in await write_file(db, "/preferences/editor.md", "Uses tabs.")
assert await cat(db, "/preferences/editor.md") == "Uses tabs."
assert "preferences/" in await ls(db, "/")
diff = await edit(db, "/preferences/editor.md", "tabs", "2-space indent")
assert "2-space indent" in diff
hit = await search(db, "indent")
assert "editor.md" in hit
assert "**indent**" in hit # fails if the index loses HIGHLIGHTS
assert await cat(db, "/missing.md") == "Error: no such file: /missing.md"
print("all checks passed")
await db.close()
if __name__ == "__main__":
asyncio.run(demo())