Why We Built a Zanzibar-Style Filesystem for AI Agents

S
Simon Morris

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:

  1. Implied relationsowner implies editor, which implies viewer. Check for viewer and it matches all three.
  2. 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:

  1. Expand implied relations. If checking viewer, also accept editor and owner (because owner → editor → viewer via config). This expansion is driven entirely by zan_relation_config — the code walks the implication graph at runtime.
  2. 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.
  3. Follow userset rewrites. For each tuple where subject_relation is non-null, recursively check whether the original subject holds that relation on the referenced object.
  4. Cycle detection. A visited set prevents infinite loops (e.g., circular userset rewrites). A maxDepth guard 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:

  1. AgentFileService.ensureDirectoryChain() creates the directory hierarchy
  2. Ownership tuples are written: the agent gets owner, the team lead gets editor
  3. Seed files are written: SOUL.md, IDENTITY.md, MEMORY.md, and a memory/ 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