One namespace of everything an agent can see, search, and change. You decide what's in it.
Frameworks build the agent. Control planes set the policy. Gateways check the tool call. vfs enforces it on the data itself — the files, rows, and tools the agent actually touches.
pip install "vfs-py>=0.1.0a1,<0.2"Your agent has driven Unix through its entire pretraining — it already knows ls, grep, and cat. Mount your database as a file system and that training becomes your interface: no tool schemas to design, no prompt engineering to teach. And because the namespace is backed by the database you already run, there is no new infrastructure to stand up either — it works for the agent, and it works for you.
A VirtualFileSystem node routes a namespace. Give it a storage backend and it serves files; mount other storages into it and it routes across them. Agents see one tree; each subtree is answered by whatever store owns it. The same verbs come in two faces with one import line between them: VirtualFileSystem is synchronous, for scripts and notebooks; AsyncVirtualFileSystem is the async kernel, for servers and agents.
Synchronous, for scripts and notebooks:
from vfs import DatabaseStorage, VirtualFileSystem
with VirtualFileSystem() as root:
root.add_mount(DatabaseStorage(url="sqlite+aiosqlite:///knowledge.db"), "/knowledge")
written = root.write(path="/knowledge/policies/mfa.md", content="All logins require MFA.", parents=True)
found = root.grep("MFA", paths=("/knowledge",))
assert written.success and found.successAsynchronous, for servers and agents:
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from vfs import AsyncVirtualFileSystem
# Your application's engine and session factory — any SQLAlchemy URL works.
engine = create_async_engine("sqlite+aiosqlite:///enterprise.db")
sessions = async_sessionmaker(engine, expire_on_commit=False)
kernel = AsyncVirtualFileSystem()
await kernel.add_mount(DatabaseStorage(session_factory=sessions), "/enterprise")
await kernel.add_mount(DatabaseStorage(url="sqlite+aiosqlite:///knowledge.db"), "/knowledge", permissions="read")
written = await kernel.write(path="/enterprise/policies/mfa.md", content="All logins require MFA.", parents=True)
result = await kernel.grep("MFA", paths=("/enterprise",))
ranked = await kernel.glean("do logins need a second factor?", limit=10)
assert written.success and result.success and ranked.successA VirtualFileSystem() built with no storage gets an InMemoryStorage at /: a DatabaseStorage over a private in-process SQLite database. Writes outside every mount land there and live as long as the process; pass storage= to put something durable at the root.
Mounts can be bound at any depth, and every path an agent sees stays in its own coordinates: results are rebased on the way back up, so /enterprise/policies/mfa.md is the same name no matter which store answered it.
Every node speaks the same small vocabulary. Verbs take paths in, return one composable Result out.
| Family | Verbs | What they answer |
|---|---|---|
| Read | read · stat · ls · tree |
point reads and listings |
| Search | glob · grep · glean · graph |
where it sits · what it says · what it means · how it connects |
| Mutate | write · edit · delete · mkdir · move · copy · mkedge |
changes; delete is reversible through the trash and restore |
| Execute | run |
invoke a tool that lives at a path |
Every family follows the same contract — paths in, one Result out.
A file carries information four ways — where it sits, what it says, what it means, and how it connects. VFS gives each signal its own verb, and all four return the same composable result type, so they combine instead of competing.
await kernel.glob("**/*.md", paths=("/enterprise",)) # where it sits — match names and structure
await kernel.grep("logins", ext=(".md",), before_context=2) # what it says — match content, ripgrep-style
await kernel.glean("do logins need a second factor?", limit=10) # what it means — one fused, ranked list
await kernel.graph("inbound", "/enterprise/policies/mfa.md") # how it connects — what points at this policyglobanswers by location: names and structure, the shape of the tree.grepanswers by content: literal or regex matching with the options agents already know — case modes, context lines, include/exclude globs.gleananswers by meaning: text in, one ranked list out. The caller never picks a retrieval strategy — the backend fuses whatever signals it has (lexical, vector, graph) and owns the ranking. With no embedding provider configured, the lexical leg answers alone and the result says so.graphanswers by connection: typed edges between files are first-class, and four methods walk them like any other query —inbound(what points at this),outbound(what this points to),neighborhood(both), andmeet(the subgraph that joins a set of files). A walk is one chunked SQL statement per hop, bounded by the nodes it reaches on every engine; the caller's rights are judged on every hop, so nothing is ever reached through a file the caller may not name. The answer carries the nodes with their distance and every edge among them, drawn as a tree with the edge types on the lines.
Searches scope naturally: pass paths= to search a region, or nothing to search the whole namespace — the query fans out to every mount that can answer it, and mounts that can't are skipped rather than failing the sweep.
Every verb returns a Result carrying typed Observation rows. Results merge with |, so multi-mount answers compose losslessly — and failures are values, not exceptions: errors come back classified (not_found, wrong_kind, unsupported, permission, invalid, cross_mount, …) so an agent can branch on the kind instead of parsing a traceback. A raw exception always means a bug, never a bad input. Results also round-trip a wire payload (to_payload / from_payload), which is what will cross the MCP boundary as structured content.
Files live in the user namespace; everything derived from them lives at reserved paths beneath it:
/enterprise/auth.py the file
/.vfs/enterprise/auth.py/__meta__/
├── chunks/login a function, addressable on its own
└── edges/out/imports/enterprise/utils.py a typed link to another file
/.agents/tools/<name>/ a tool an agent can discover and run
Chunks, edges, tools, and skills are ordinary entries: ls them, grep them, mkedge new links, run a tool. One abstraction, one set of verbs.
A node holds its storage rather than being it. Backends implement small protocol families — reads, pattern search, ranked search, mutation, graph, run — and a backend claims only the families it genuinely supports. A node's capabilities() are derived from what its backend actually declares, so a partial backend (an MCP tool catalog that only does ls/read/run, a store without ranked search) is a first-class citizen, not a liar.
from vfs import InMemoryStorage, VirtualFileSystem
node = VirtualFileSystem(storage=InMemoryStorage()) # a DatabaseStorage on a private in-process SQLite
assert {"read", "glob", "grep", "glean", "write"} <= node.capabilities()
node.close()Database backends come in exactly two constructions: give one a URL and it builds and owns its engine, or hand it your application's session factory and it borrows yours — ownership follows construction, never a flag.
Permissions compose the same way: each node carries a permission map ("read", "read_write", or a per-prefix map of those two), enforced at the routing chokepoint before anything dispatches. The map lives on the node, not on the data: two nodes over one schema do not see each other's maps.
The namespace is designed to be an agent's whole toolbox behind one MCP tool: verbs as the call surface, Result payloads as structured content, tools discovered at paths instead of preloaded into context. The same design runs the other direction — mounting external MCP servers into the tree as just another backend. The wire contract and MCP mount stories are drafted, not built.
What the tree does not do yet, and where each decision lives.
- Permissions are enforced at the node. The mount-level and per-prefix map is checked before dispatch; row-level grants are landed (spec 058).
- A fresh storage's posture is
read_write. Until its posture changes, an anonymous caller may read and write on it. Row grants and postures take the rungs of one ladder —none,list,read,add,contribute,read_write— and every verb's rung is one entry in a table. copyandmovedo not cross a mount boundary. They come backcross_mount; copy a file across mounts withreadandwrite(spec 160 lifts this for files).editdoes not touch a binary body. It refuseswrong_kind; replace the bytes withwrite(data=...)(spec 154).- Neither face is a storage and neither can be mounted.
add_mounttakes a storage backend (spec 156 makes the refusal explicit).
- Implemented (v2 core): typed path grammar, models, the classified result channel, the op vocabulary, node permissions, the full async mount router — spine listings, capability gating, batched and fanned-out dispatch — and the database backend on the storage protocol: reads, writes,
glob,grep,gleanwith its lexical and vector legs and the cross-mount rerank, the trash arc, identity and row-level grants, binary bodies with text renderings. 4,564 tests at 100% coverage, green on SQLite, Postgres, MariaDB, SQL Server and Oracle. - Under review: row-level grants at scale (specs 058 and 150) and binary bodies (spec 152).
- Next: the MCP surface (serve the namespace as one tool; mount MCP servers), the local file backend, the graph engine re-homed behind its protocol family, tools at paths, and a version row on every mutation.
- Released: the newest alpha of this rebuild is on PyPI as
vfs-py; the releases are the changelog. The0.0.xreleases are yanked.
Contributions are welcome. See CONTRIBUTING.md.
Apache 2.0. See LICENSE.