> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mirage.strukto.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Adding a New VFS

> Ship your own backend with GenericVFS, or contribute a builtin VFS using Mirage's VFS, command, and snapshot conventions.

A VFS maps an external system to Mirage's filesystem operations and shell commands. There are two paths:

* **Ship your own backend**: a single TypeScript file in your own project or package, built on `GenericVFS`. No Mirage fork, no edits to Mirage source.
* **Contribute a builtin**: the four-layer layout inside the Mirage repo, mirrored in Python.

## Ship Your Own Backend

Write the core functions over your data source, put them on a `CommandIO` table, and `GenericVFS` wires the full generic command set (`ls`, `cat`, `grep`, `find`, `head`, `wc`, ...) plus glob resolution and the VFS/FUSE ops:

```ts theme={null}
import {
  Accessor,
  type CommandIO,
  FileStat,
  GenericVFS,
  MountMode,
  type PathSpec,
  streamFromBytes,
  Workspace,
} from '@struktoai/mirage-node'

class JiraAccessor extends Accessor {
  constructor(readonly client: JiraClient) {
    super()
  }
}

declare function readdir(accessor: JiraAccessor, path: PathSpec): Promise<string[]>
declare function readBytes(accessor: JiraAccessor, path: PathSpec): Promise<Uint8Array>
declare function stat(accessor: JiraAccessor, path: PathSpec): Promise<FileStat>

class JiraVFS extends GenericVFS<JiraAccessor> {
  constructor(config: JiraConfig) {
    super({
      name: 'jira',
      accessor: new JiraAccessor(makeClient(config)),
      io: {
        readdir,
        readBytes,
        readStream: (a, p, i) => streamFromBytes(readBytes, a, p, i),
        stat,
        isMounted: () => true,
        local: false,
      },
      prompt: 'Issues rendered as .json files.',
    })
  }
}

const ws = new Workspace({ '/jira/': new JiraVFS(cfg) }, { mode: MountMode.READ })
```

The accessor is a type parameter, so the table is checked against the accessor your core functions actually take: wiring `readdir` where `stat` belongs, or an accessor from another backend, is a compile error rather than a runtime one.

Only four table fields are required. The optional ones unlock more surface: `write` enables the byte-mutation family, `find` and `du` become native fast paths, and a command whose requirements the table cannot meet is never registered rather than registered and broken. The escape hatches are the ones the builtins use, because `GenericVFS` assembles exactly what they assemble by hand:

* `overrides` drops a generic command you replace, and `commands` supplies the replacement (or any bespoke verb) from `command({...})`.
* `ops` layers an irregular VFS/FUSE handler over the derived set; one carrying no `filetype` shadows the derived op of the same name. `autoOps: false` opts out of deriving any.
* `sizesAlwaysKnown` declares that `stat` sizes every file without fetching it, which is also what makes the mount legal on FSKit. `supportsSnapshot` declares that `stat` fills `FileStat.fingerprint`; setting it without that is not drift detection.

To make the backend constructible by name (workspace config, snapshots, the daemon), register a factory:

```ts theme={null}
import { registerVfsFactory } from '@struktoai/mirage-node'

registerVfsFactory('jira', (config) => Promise.resolve(new JiraVFS(config as JiraConfig)))
```

The registry takes a factory rather than a class because a browser backend is often reached through a dynamic import; `buildVfs('jira', config)` then works exactly as it does for a builtin.

Registering is not needed to mount from config. A `vfs` value carrying a colon names the class directly, the same way a `clis` entry's `cli` value names a spec tree, so a deployment can point at a file next to the config or at a class inside an installed package:

```yaml theme={null}
mounts:
  /jira:
    vfs: ./jira.mjs:JiraVFS
  /wiki:
    vfs: my-backends:WikiVFS
```

A relative path resolves against the config file's directory, not the server's working directory; a bare specifier is Node's to resolve, so a package name is left alone. A registry name always wins over a reference, so a name can never be reread as code. A `static async create` is honored ahead of the constructor, which is how a backend whose setup needs I/O is spelled here.

Snapshots and versions reach the registry too: `Workspace.load` rebuilds a saved mount through the registered name (or the `./jira.mjs:JiraVFS` reference config named), the same way Python's loader does. What comes back depends on what the VFS owns. Content the VFS holds itself (an in-memory store) is mirage-owned state: override `getState` and `loadState` to carry it, and a snapshot or a version restores the mount with that content and no override. Content that lives in a remote service is only observed: keep the default state, which says `needs_override`, set `supportsSnapshot` and fill `FileStat.fingerprint`, and a snapshot pins what it read while `Workspace.load` asks for the live VFS back through its overrides (`Workspace.load(state, {}, { '/jira/': new JiraVFS(cfg) })`). A forgotten override is a refusal to load, never a mount that comes back empty. `Workspace.copy` needs nothing, since it passes the live VFS through. `examples/typescript/other/custom_vfs.ts` shows both halves: a wiki page is written, the workspace is snapshotted, the page is changed, and the loaded workspace serves the page as it was, while a feed mount that keeps the default state is refused until the load hands it back through its overrides.

See `examples/typescript/other/custom_vfs.ts` for a complete runnable backend in one file, and `examples/python/other/custom_vfs.py` for its Python twin. Both are asserted against the same truth file, so the two SDKs cannot drift.

## Contribute a Builtin VFS

Builtins live inside the Mirage repo with the four-layer layout below. Keep the core I/O layer independent from command parsing, and check whether the same VFS or behavior should also be added to Python.

Pick the package by runtime, not by preference: `packages/core` for a backend that works in both the browser and Node, `packages/node` for one that needs Node APIs, `packages/browser` for one that needs a browser transport. Use a recent VFS such as Qdrant or LanceDB as the structural reference. Paths are always `PathSpec` values inside the VFS; do not pass filesystem paths as raw strings.

## File Structure

```text theme={null}
typescript/packages/<core|node|browser>/src/
  vfs/<name>/
    config.ts
    prompt.ts
    <name>.ts
  accessor/<name>.ts
  core/<name>/
    read.ts
    readdir.ts
    stat.ts
  ops/<name>/
    index.ts
  commands/builtin/<name>/
    index.ts
    io.ts
    <vfs-specific commands>.ts
```

Tests are colocated: `<name>.test.ts` beside the source it covers.

## 1. Config, Accessor, and Registry

Define the config as an interface plus a `resolve<Name>Config` that fills every default, and keep secrets out of anything that gets serialized. Create an `Accessor` subclass that owns the client or transport. Add the VFS name to `VFSName`, and add a factory to the runtime package's `vfs/registry.ts`; that entry is what workspace config, snapshots, and the daemon construct through.

Config keys arrive snake\_case from YAML shared with Python and are mapped by `normalizeFields`, which already sends every unlisted key through `snakeToCamel`. Add a rename entry only for a key that mapping gets wrong.

Keep every import at module scope. If that would create a cycle, change the dependency direction instead of adding a lazy import inside a function.

## 2. Core VFS Operations

Implement only the operations the backend supports. A read-only API-backed VFS usually starts with:

* `readdir(accessor, path, index?)` returning child paths.
* `read(accessor, path, index?)` returning bytes.
* `stat(accessor, path, index?)` returning a `FileStat`.

`FileStat.size` must be the rendered content's byte length or `null`, never a storage-side number: a confidently wrong size makes `wc -c` and `ls -l` lie over FUSE, while `null` rides the unknown-size machinery. Put the storage number in `extra` if it is worth reporting.

Glob resolution is not a per-backend file: bind it from readdir with `makeResolveGlob(readdir, cap)`, or let `GenericVFS` derive it from the table.

## 3. Ops Layer

Ops are the workspace dispatcher's typed adapters onto the core functions, and they are generated, not hand-written:

```ts theme={null}
import { QDRANT_IO } from '../../commands/builtin/qdrant/io.ts'
import { VFSName } from '../../types.ts'
import { makeGenericOps } from '../generic/factory.ts'
import type { RegisteredOp } from '../registry.ts'

export const QDRANT_OPS: readonly RegisteredOp[] = makeGenericOps(VFSName.QDRANT, QDRANT_IO)
```

Write an op by hand only for an irregular handler, and pass its name through `overrides` so the derived set skips it. Mark every mutation `write: true`.

## 4. Commands

Build the standard command set with `makeGenericCommands` over the same table; the generic command owns flag interpretation, so a backend wrapper is wiring only. Export the result as `<NAME>_COMMANDS` from `commands/builtin/<name>/index.ts`.

For a VFS-specific command:

* Use the shared command spec (`specOf`), or a `new CommandSpec({...})` for a verb with its own grammar.
* Mark every mutation `write: true` so `MountMode.READ` stays a real boundary.
* Read a flag through `new FlagView(flags, specOf('<name>'))`, never `flags.get(...)`.
* Add a provision estimator when a useful estimate is possible; otherwise the planner reports `precision: unknown`.

## 5. VFS Class

Import the command and op arrays at module scope and return them from `commands()` and `ops()`:

```ts theme={null}
export class MyVFS extends BaseVFS implements VFS {
  readonly kind: string = VFSName.MY_VFS
  readonly cachesReads: boolean = true
  readonly prompt: string = MY_PROMPT
  readonly accessor: MyAccessor

  constructor(config: MyConfig) {
    super()
    this.config = resolveMyConfig(config)
    this.accessor = new MyAccessor(this.config)
  }

  open(): Promise<void> {
    return Promise.resolve()
  }

  ops(): readonly RegisteredOp[] {
    return MY_VFS_OPS
  }

  commands(): readonly RegisteredCommand[] {
    return MY_VFS_COMMANDS
  }
}
```

Set `cachesReads` true only for stable, read-mostly content. Override `getState()` to carry the (redacted) config, and `close()` to release any client handles, calling `super.close()`, which closes the index store.

A config-backed backend also sets `needs_override: true` in that state. TypeScript's loader consults no registry, so a mount it was not handed comes back as an empty `RAMVFS`; the flag makes it refuse instead. Python does not need it, which is why the flag is written on only a handful of mounts there and read on none.

## 6. Snapshot Support

Leave `supportsSnapshot` unset unless the complete drift contract is implemented:

1. `stat()` returns a stable `FileStat.fingerprint`.
2. Every read record includes the fingerprint that produced those bytes.
3. If the backend supports immutable revisions, reads consult the resolved revision and record it.

Setting the flag without recording fingerprints does not provide drift detection.

## 7. Verification

Add tests for config resolution, path layout, every VFS op, command behavior, read-only enforcement, state redaction, and cleanup. For major features, add or update integration coverage under `integ/`, and check Python/TypeScript parity before opening the PR.
