Why We Built a Zanzibar-Style Filesystem for AI Agents
How Google's authorization model became the foundation for multi-tenant agent workspaces
Agentic AI frameworks cannot treat the agent as stateless. This isn't just a function that takes a prompt and returns a response. The agent needs a home. With a persistent workspace it can call its own. Every conversation needs to start with the context of what has happened before.
This was a recent challenge we faced building Zontally.
At Zontally, our platform ships AI agents - a Chief of Staff for every team, a Leadership Coach for every employee - that need to accumulate context over time. They need to remember preferences, store drafts, read strategy documents, and build an evolving understanding of the people they work with.
That means agents need a filesystem. But not just any filesystem — one that handles thousands of isolated workspaces across a multi-tenant SaaS platform, where both humans and agents read and write files, and where permissions cascade cleanly without per-file access control lists.
We found our answer by taking inspiration from Google's Zanzibar authorization system.
The Problem: One Agent Definition, Thousands of Homes
Our platform has a small number of agent archetypes - currently Chief of Staff and Leadership Coach, and we are planning more in the future. But each archetype is instantiated many times. A Chief of Staff agent is deployed per team. A Leadership Coach is deployed per employee.
These Digital Employees need to store and retrieve the context of the team or individual they serve. The human owner of the agent also needs a simple way to interpret what the agent knows - it's memory and instructions.
/home/cos/{team_sys_id}/ ← one chief of staff per team
/home/coach/{user_sys_id}/ ← one per employee
Each home directory contains markdown files the agent reads at runtime — its soul definition, identity, memory, context documents — and the agent needs to read and write these files during conversations. Meanwhile, the humans who manage each agent (team leads, individual employees) also need to read and edit these files through a browser UI.
Both agents and humans excel at reading and understanding simple text files - no complicated UI setup to provide instructions on how you want your leadership coach to talk to you.
This creates a permission model that traditional RBAC doesn't handle well:
- The agent owns its home directory
- The team lead (or the employee) has editor access to the same directory
- Files inside the directory should inherit permissions from their parent
- Subdirectories at arbitrary depth should cascade permissions automatically
- All of this needs to be tenant-isolated in a multi-tenant system
We needed a permission model that was relational (who has what access to what), hierarchical (permissions cascade through containment), and composable (new relations can be added without code changes).
That's exactly what Zanzibar does.
What Is Zanzibar?
Google Zanzibar is the authorization system behind Google Drive, YouTube, Cloud, and most other Google products. Its core primitive is the relation tuple:
object#relation@subject
A tuple states that a subject holds a relation on an object. For example:
document:readme#viewer@user:alice
This says "Alice is a viewer of the document called readme."
What makes Zanzibar powerful isn't the tuple itself — it's two features that compose on top of it:
- Implied relations —
ownerimplieseditor, which impliesviewer. Check forviewerand it matches all three. - Userset rewrites — the subject of a tuple can be "anyone who holds relation R on object O", not just a concrete user. This is how permissions cascade through hierarchies without duplicating tuples.
We implemented both in the Zontally platform
The Data Model
Four tables power the system:
zan_namespace — Object Types
Defines the types of objects in the system. We have two: file and directory.
INSERT INTO zan_namespace (name, description)
VALUES ('file', 'A file in the agent filesystem'),
('directory', 'A directory in the agent filesystem');
zan_relation_config — Relation Definitions
Defines the valid relations for each namespace, and crucially, implied relations:
-- For the 'file' namespace:
('owner', '["editor"]') -- owner implies editor
('editor', '["viewer"]') -- editor implies viewer
('viewer', '[]') -- viewer implies nothing further
('parent', '[]') -- structural containment
This chain is config-driven, not hardcoded. Adding a new relation like commenter or changing the implication hierarchy is a database update, not a code deploy.
zan_tuple — The Relation Store
The workhorse. Every permission grant, every containment relationship, every inheritance rule is a row in this table:
CREATE TABLE zan_tuple (
namespace VARCHAR(255) NOT NULL,
object_id VARCHAR(1024) NOT NULL,
relation VARCHAR(255) NOT NULL,
subject_namespace VARCHAR(255) NOT NULL,
subject_id VARCHAR(1024) NOT NULL,
subject_relation VARCHAR(255), -- nullable: enables userset rewrites
sys_domain VARCHAR(64), -- tenant isolation
sys_id VARCHAR(64) PRIMARY KEY
);
The subject_relation column is the key to Zanzibar's power. When it's NULL, the tuple is a direct grant:
directory:home/cos/team1#owner@ai_agent:cos
"The CoS agent owns the team1 home directory."
When subject_relation is set, it's a userset rewrite:
file:home/cos/team1/PROMPT.md#editor@directory:home/cos/team1#editor
"Anyone who is an editor of directory home/cos/team1 is also an editor of the file PROMPT.md."
This single mechanism replaces what would otherwise require copying permission tuples to every file in a directory.
zan_file — Content Store
The actual file content, stored alongside its path:
CREATE TABLE zan_file (
path VARCHAR(1024) NOT NULL UNIQUE,
content TEXT,
content_type VARCHAR(255) DEFAULT 'text/markdown',
size_bytes INTEGER DEFAULT 0,
sys_domain VARCHAR(64),
sys_id VARCHAR(64) PRIMARY KEY
);
The Check Algorithm
The ZanzibarStore.check() method is the authorization gate. Every file read, write, list, and delete passes through it. Here's the logic:
async check(
namespace: string, // 'file' or 'directory'
objectId: string, // the path
relation: string, // 'viewer', 'editor', or 'owner'
subjectNamespace: string, // 'user', 'ai_agent', etc.
subjectId: string, // the user or agent sys_id
domainScope: ZanzibarDomainScope,
maxDepth = 10,
): Promise<boolean>
The algorithm:
-
Expand implied relations. If checking
viewer, also accepteditorandowner(becauseowner → editor → viewervia config). This expansion is driven entirely byzan_relation_config— the code walks the implication graph at runtime. - Check direct tuples. Look for a tuple where the subject directly holds any of the effective relations on the object, within the tenant's domain scope.
-
Follow userset rewrites. For each tuple where
subject_relationis non-null, recursively check whether the original subject holds that relation on the referenced object. -
Cycle detection. A
visitedset prevents infinite loops (e.g., circular userset rewrites). AmaxDepthguard caps recursion at 10 levels.
// Simplified core logic
const effectiveRelations = await getEffectiveRelations(namespace, relation);
// Step 1: Direct match
const directMatch = await tupleModel.findOne({
where: {
namespace, object_id: objectId,
relation: { [Op.in]: effectiveRelations },
subject_namespace: subjectNamespace,
subject_id: subjectId,
subject_relation: null, // direct grants only
sys_domain: { [Op.in]: domainScope.domains },
},
});
if (directMatch) return true;
// Step 2: Userset rewrites
const rewriteTuples = await tupleModel.findAll({
where: {
namespace, object_id: objectId,
relation: { [Op.in]: effectiveRelations },
subject_relation: { [Op.not]: null }, // rewrites only
sys_domain: { [Op.in]: domainScope.domains },
},
});
for (const rewrite of rewriteTuples) {
// Recurse: does the original subject hold subject_relation
// on the rewrite's subject object?
if (await _check(
rewrite.subject_namespace,
rewrite.subject_id,
rewrite.subject_relation,
subjectNamespace, subjectId,
domainScope, maxDepth, depth + 1, visited
)) return true;
}
return false;
In practice, agent file trees are shallow (2–4 levels), so recursion depths of 3–5 are typical. The 5-column composite index on zan_tuple keeps these lookups fast:
CREATE INDEX idx_zan_tuple_object_subject
ON zan_tuple (namespace, object_id, relation, subject_namespace, subject_id);
Automatic Permission Inheritance
When a file is created inside a directory, AgentFileService automatically writes three userset rewrite tuples:
const inheritanceTuples = [
{ relation: 'owner', subject_relation: 'owner' },
{ relation: 'editor', subject_relation: 'editor' },
{ relation: 'viewer', subject_relation: 'viewer' },
].map(({ relation, subject_relation }) => ({
namespace: 'file',
object_id: filePath,
relation,
subject_namespace: 'directory',
subject_id: parentDirPath,
subject_relation,
}));
await ZanzibarStore.write(inheritanceTuples, sysDomain);
The same happens for subdirectories. This means granting a user editor on a top-level directory automatically grants editor on every file and subdirectory within it — through the recursive check, not through tuple duplication.
The tuple count stays proportional to the number of explicit grants, not the number of files. A directory with 50 files and one editor has 50 × 3 inheritance tuples + 1 direct grant = 151 tuples. Without userset rewrites, you'd need 50 direct grants.
Lazy Provisioning
Home directories aren't created when an agent is deployed. They're created on first use. When a CoS agent runs for Team X and no home directory exists:
AgentFileService.ensureDirectoryChain()creates the directory hierarchy- Ownership tuples are written: the agent gets
owner, the team lead getseditor - Seed files are written:
SOUL.md,IDENTITY.md,MEMORY.md, and amemory/directory
async provisionAgentHome(agentType, contextKey, editorUserSysId, seed, memoryContent) {
const ctx = await AgentFileService.buildContext(agentType, contextKey, ...);
await AgentFileService.ensureAgentBrowseHierarchy(agentType, contextKey, ctx.sysDomain);
await AgentFileService.mkdir('.', ctx);
await AgentFileService.mkdir('memory', ctx);
await AgentFileService.ensureSeedFile('SOUL.md', seed.soulContent, ctx);
await AgentFileService.ensureSeedFile('IDENTITY.md', seed.identityContent, ctx);
await AgentFileService.ensureSeedFile('MEMORY.md', memoryContent, ctx);
}
The seed content varies by agent archetype. A Chief of Staff gets a soul focused on radical candor, outcome-orientation, and team alignment. A Leadership Coach gets one focused on active listening, growth conversations, and career development. Of course, once seeded the human owner of the agent can develop the personality and instruction set to make the agent their own. "Talk like a pirate" is a text file edit away. Aharr.
The Virtual Filesystem: Platform Data as Files
Here's where it gets interesting. Not everything in an agent's filesystem is a real file in zan_file. Agents also need to read live platform data — team rosters, user profiles, OKR progress, kudos, brilliant basics compliance. Storing snapshots of this data as files would be immediately stale.
Instead, we built a virtual filesystem layer — conceptually similar to Linux's /proc or FUSE — that presents live platform data as files the agent can read and write through the same filesystem interface.
The VirtualPathResolver sits between AgentFileService and the ORM. When a path starts with platform/, it's dispatched to a registered handler instead of hitting zan_file:
home/cos/team1/SOUL.md → real file (zan_file)
home/cos/team1/memory/today.md → real file (zan_file)
platform/users/simon.morris/profile.json → virtual (UserPathHandler)
platform/teams/engineering/members.json → virtual (TeamPathHandler)
platform/objectives/Q3-growth/progress.json → virtual (ObjectivePathHandler)
Each handler implements a simple interface:
interface IVirtualPathHandler {
matches(fullPath: string): boolean;
list(subPath: string, ctx: VirtualFsContext): Promise<VirtualFileEntry[]>;
read(subPath: string, ctx: VirtualFsContext): Promise<VirtualReadResult | null>;
write(subPath: string, content: string, ctx: VirtualFsContext): Promise<VirtualWriteResult>;
search(query: string, subPath: string, ctx: VirtualFsContext): Promise<VirtualFileEntry[]>;
}
We currently ship 8 handlers covering users, teams, goals, objectives, key results, kudos, brilliant basics, and schema introspection. The agent sees a unified filesystem; it doesn't need to know which parts are real files and which are live queries.
The write path on virtual handlers is particularly powerful — when an agent writes to platform/users/jane.doe/profile.json, the UserPathHandler validates the JSON, maps it to the underlying ORM fields, and applies the update. The agent manipulates platform data through the same file_write tool it uses for its own notes.
Multi-Tenant Isolation
Every table carries a sys_domain column. ZanzibarStore.check() always includes a domain scope clause:
private static domainWhere(domainScope: ZanzibarDomainScope) {
return { sys_domain: { [Op.in]: domainScope.domains } };
}
The domain scope is resolved from the agent's home path — home/cos/{contextKey} maps to the ai_digital_employee record for that context key, which carries its tenant's sys_domain. This means:
- A tuple in tenant A is invisible to checks in tenant B
- An agent in tenant A cannot read files in tenant B
- The resolution happens at the path level, not via session cookies or middleware
ZanzibarDomainScope.ts handles the resolution:
async function resolveResourceDomainFromPath(path: string): Promise<string> {
const match = /^home\/(cos|coach|ea)\/([^/]+)/.exec(path);
if (match) {
const digitalEmployeeSysId = match[2];
const record = await ORMModelManager.getModel('ai_digital_employee')
.findOne({ where: { sys_id: digitalEmployeeSysId } });
return record.get('sys_domain');
}
throw new Error(`Cannot resolve resource domain from path "${path}"`);
}
Agent Tool Integration
At runtime, agents don't see absolute paths. They don't know their context key or the internal home path structure. The session carries the agent type and context key, and resolveAgentFileContextForTool builds the context server-side:
async function resolveAgentFileContextForTool(options) {
const userSysId = resolveUserSysIdForTool(undefined, options);
const { agentType, contextKey } = resolveAgentWorkspaceForTool(undefined, options);
return AgentFileService.buildContext(agentType, contextKey, 'user', userSysId);
}
The agent's tool calls use relative paths: file_read("SOUL.md"), file_write("memory/2026-07-11.md", content), file_list("."). The resolution to absolute paths and the permission check happen entirely on the server. There's no path traversal risk — .. is rejected, and everything is scoped to the home directory.
What We Didn't Build
Zanzibar at Google-scale includes features we intentionally skipped - maintaining an enterprise software platform is a very different problem to the global scale and consistency that Google needs, so we were able to be selectively inspired by their implementation.
- Zookies (consistency tokens) — our tuple count is modest and we don't need cross-region consistency guarantees
- Leopard indexing (materialised permission cache) — direct SQL with composite indices handles our throughput. We'll add this if/when we see latency issues.
- Content-change tokens — not needed for a filesystem use case
- Graph database — the tuple table with in-memory recursion handles 2–4 levels of depth comfortably. No need for Neo4j or a CTE-heavy approach.
We also kept the permission model deliberately simple: three relations (owner, editor, viewer) with a single implication chain. The system supports arbitrary relations via config, but we haven't needed more yet.
The Numbers
The full implementation across backend, virtual filesystem, migration, tests, GraphQL layer, and frontend UI comes to roughly 12,000 lines of TypeScript — about 5,300 for the core system and 6,400 for the virtual filesystem handlers.
Key file counts:
ZanzibarStore.ts— 462 lines (the core tuple engine)AgentFileService.ts— 1,431 lines (filesystem API with lazy provisioning)VirtualPathResolver.ts— 358 lines (the FUSE-like dispatch layer)- 8 virtual filesystem handlers — ~6,400 lines total
- Test specs — ~1,000 lines covering recursive permission expansion, userset rewrite chains, domain isolation, and cycle detection
What We Learned
Zanzibar is simpler than it looks. The original paper is dense, but the core algorithm is three steps: check direct tuples, expand implied relations, follow userset rewrites. Our entire tuple engine is under 500 lines.
Userset rewrites are the key insight. Without them, we'd be duplicating permission tuples for every file in every directory. With them, the tuple count stays proportional to explicit grants, and permissions cascade automatically through containment.
Config-driven relations pay off immediately. During development, we changed the implication chain several times. Because it's data in zan_relation_config, not logic in code, each change was an UPDATE statement.
Virtual filesystems unify the agent's world. The decision to present live platform data as files — rather than giving agents a separate set of "data query" tools — simplified the agent's cognitive model. It has one tool for reading information (file_read) regardless of whether that information is a markdown note or a team roster.
Lazy provisioning is essential at scale. With potentially thousands of agent home directories, eager creation on deploy would be wasteful. Most directories would sit empty. Creating on first write means we only pay the overhead for directories that are actually used.
The Zanzibar agent filesystem is live in production. Every Chief of Staff and Leadership Coach in the platform reads and writes through it. In a follow-up post, we'll walk through how we structure the agent home directory itself - the soul files, memory system, and the conventions that give our agents persistent identity.
Continue to stay in touch with the development of the Zontally platform through our blogs